chore(themes): add new themes api v2, add in-app theme editor/creator, macos compatibility, fix asset loading, rework settings and setup wizard

This commit is contained in:
HorizonCode committed 2026-10-29 10:53:12 +01:00
1 parent 417f54a94a
commit 87202de656
97 files changed
+10968 -1422

No files matched your search

+4
View File
@@ -6,6 +6,10 @@
@import "@fontsource-variable/sora";
@import "@fontsource/space-mono";
@import "@fontsource/bebas-neue";
@import "@fontsource-variable/montserrat";
@import "@fontsource-variable/playfair-display";
@import "@fontsource-variable/jetbrains-mono";
:root {
--radius: 0.625rem;
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
type ConfettiCannonProps = {
colors?: string[];
count?: number;
};
let { colors = ['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24'], count = 820 }: ConfettiCannonProps =
$props();
let canvas = $state<HTMLCanvasElement>();
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
width: number;
height: number;
rotation: number;
spin: number;
flutterPhase: number;
flutterSpeed: number;
swayPhase: number;
swaySpeed: number;
swayAmount: number;
terminalVelocity: number;
wind: number;
color: string;
delay: number;
age: number;
lifetime: number;
};
onMount(() => {
const element = canvas;
if (!element) return;
const context = element.getContext('2d');
if (!context) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const random = (min: number, max: number) => Math.random() * (max - min) + min;
let width = 0;
let height = 0;
let dpr = 1;
let frame = 0;
let lastTime = 0;
let elapsed = 0;
let particles: Particle[] = [];
const spawn = () => {
const rect = element.getBoundingClientRect();
width = rect.width || window.innerWidth;
height = rect.height || window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
element.width = Math.max(1, Math.round(width * dpr));
element.height = Math.max(1, Math.round(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
particles = Array.from({ length: count }, (_, index) => {
const fromLeft = index % 2 === 0;
const direction = fromLeft ? 1 : -1;
const streamer = Math.random() < 0.35;
const pieceWidth = streamer ? random(3, 5) : random(5, 8);
const pieceHeight = streamer ? random(12, 24) : random(7, 14);
return {
x: fromLeft ? random(0, 28) : width - random(0, 28),
y: height - random(0, 16),
// Aim inward and up from each bottom corner. Horizontal drag and
// gravity turn the straight launch into a natural arc.
vx: direction * random(160, 900),
vy: -random(1050, 1450),
width: pieceWidth,
height: pieceHeight,
rotation: random(0, Math.PI * 2),
spin: random(-4, 4),
flutterPhase: random(0, Math.PI * 2),
flutterSpeed: random(2.5, 6.5),
swayPhase: random(0, Math.PI * 2),
swaySpeed: random(2.2, 4.2),
swayAmount: random(4, 14),
terminalVelocity: random(130, 230),
wind: random(-8, 8),
color: colors[Math.floor(Math.random() * colors.length)],
delay: random(0, 0.25),
age: 0,
lifetime: random(7, 9.5),
};
});
};
const paint = (time: number) => {
if (lastTime === 0) lastTime = time;
const delta = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
elapsed += delta;
context.clearRect(0, 0, width, height);
let alive = false;
for (const particle of particles) {
if (elapsed < particle.delay) {
alive = true;
continue;
}
if (particle.age >= particle.lifetime) continue;
particle.age += delta;
if (particle.age >= particle.lifetime) continue;
alive = true;
particle.vx *= Math.exp(-0.5 * delta);
if (particle.vy < 0) {
// The upward burst is slowed by gravity...
particle.vy += 1400 * delta;
} else {
// ...then the paper drifts down at its own slow terminal speed
// instead of accelerating like a solid object.
particle.vy += (particle.terminalVelocity - particle.vy) * (1 - Math.exp(-2.6 * delta));
}
particle.x += (particle.vx + particle.wind) * delta;
particle.y += particle.vy * delta;
particle.rotation += particle.spin * delta;
if (particle.x < -80 || particle.x > width + 80 || particle.y > height + 120) {
continue;
}
const fadeStart = particle.lifetime - 1.8;
const alpha =
particle.age > fadeStart ? Math.max(0, 1 - (particle.age - fadeStart) / 1.8) : 1;
if (alpha <= 0) continue;
const flutter = Math.cos(particle.flutterPhase + particle.age * particle.flutterSpeed);
const sway = Math.sin(particle.age * particle.swaySpeed + particle.swayPhase);
const scaleX = 0.15 + Math.abs(flutter) * 0.85;
context.save();
context.translate(particle.x + sway * particle.swayAmount, particle.y + sway * 3.5);
context.rotate(particle.rotation + flutter * 0.5);
context.scale(scaleX, 1);
context.globalAlpha = alpha;
context.fillStyle = particle.color;
context.fillRect(
-particle.width / 2,
-particle.height / 2,
particle.width,
particle.height
);
context.restore();
}
if (alive) {
frame = requestAnimationFrame(paint);
} else {
context.clearRect(0, 0, width, height);
}
};
spawn();
frame = requestAnimationFrame(paint);
return () => {
cancelAnimationFrame(frame);
};
});
</script>
<canvas
bind:this={canvas}
aria-hidden="true"
class="pointer-events-none absolute inset-0 z-20 h-full w-full"
></canvas>
@@ -0,0 +1,88 @@
<script module lang="ts">
let previewCount = 0;
</script>
<script lang="ts">
import { onMount } from 'svelte';
type Props = {
family: string;
src: string;
weight?: string;
style?: 'normal' | 'italic';
text?: string;
class?: string;
};
let {
family,
src,
weight = '400',
style = 'normal',
text = family,
class: className = '',
}: Props = $props();
let host = $state<HTMLSpanElement>();
let visible = $state(false);
let renderedFamily = $state<string>();
let face: FontFace | undefined;
onMount(() => {
if (!host) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
visible = true;
observer.disconnect();
},
{ rootMargin: '180px 0px' }
);
observer.observe(host);
return () => observer.disconnect();
});
$effect(() => {
if (!visible || !src) return;
let cancelled = false;
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
void (async () => {
try {
const candidate = new FontFace(previewFamily, `url("${src}")`, {
weight,
style,
display: 'swap',
});
const loaded = await candidate.load();
if (cancelled) return;
document.fonts.add(loaded);
face = loaded;
renderedFamily = previewFamily;
} catch {
// A preview failure should leave the row readable in the fallback font.
}
})();
return () => {
cancelled = true;
if (face && face.family === previewFamily) {
document.fonts.delete(face);
face = undefined;
}
if (renderedFamily === previewFamily) renderedFamily = undefined;
};
});
</script>
<span
bind:this={host}
class={className}
style:font-family={renderedFamily
? `"${renderedFamily}", ui-sans-serif, system-ui, sans-serif`
: undefined}
style:font-weight={weight}
style:font-style={style}
>
{text}
</span>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,392 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import Slider from '$lib/components/ui/slider/slider.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import type { ThemeFontChoice } from '$lib/themeFonts';
import type { ThemeV2Element } from '$lib/themeV2';
import { Image as ImageIcon, RefreshCw, Type } from '@lucide/svelte';
let {
element,
fontOptions,
onreplace,
}: {
element: ThemeV2Element;
fontOptions: ThemeFontChoice[];
onreplace: (kind: 'image' | 'video') => void;
} = $props();
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
</script>
<div class="space-y-4 p-4">
<div class="flex items-center gap-2 border-b border-theme-800 pb-3">
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
{#if element.type === 'text'}
<Type class="size-4" />
{:else}
<ImageIcon class="size-4" />
{/if}
</div>
<div class="min-w-0">
<p class="truncate text-xs font-semibold capitalize text-theme-50">{element.type} layer</p>
<p class="truncate font-mono text-[10px] text-muted-foreground">{element.id}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-x" class={label}>X</Label>
<Input id="element-x" class={field} type="number" step="1" bind:value={element.x} />
</div>
<div class="space-y-1">
<Label for="element-y" class={label}>Y</Label>
<Input id="element-y" class={field} type="number" step="1" bind:value={element.y} />
</div>
<div class="space-y-1">
<Label for="element-width" class={label}>Width</Label>
<Input
id="element-width"
class={field}
type="number"
min="24"
max="1000"
bind:value={element.width}
/>
</div>
<div class="space-y-1">
<Label for="element-height" class={label}>Height</Label>
<Input
id="element-height"
class={field}
type="number"
min="24"
max="650"
bind:value={element.height}
/>
</div>
<div class="space-y-1">
<Label for="element-rotation" class={label}>Rotation</Label>
<Input
id="element-rotation"
class={field}
type="number"
min="-360"
max="360"
bind:value={element.rotation}
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class={label}>Opacity</Label>
<span class="text-[10px] text-muted-foreground"
>{Math.round((element.opacity ?? 1) * 100)}%</span
>
</div>
<Slider
min={0}
max={1}
step={0.01}
value={element.opacity ?? 1}
onValueChange={(value) => (element.opacity = value)}
/>
</div>
</div>
{#if element.type === 'text'}
<div class="space-y-3 border-t border-theme-800 pt-3">
<div class="space-y-1">
<Label for="element-text" class={label}>Text</Label>
<Textarea
id="element-text"
class="min-h-20 resize-y border-theme-800 bg-theme-950 text-xs normal-case tracking-normal text-theme-50"
bind:value={element.text}
/>
</div>
<div class="space-y-1">
<Label for="element-font" class={label}>Font</Label>
<Select.Root
type="single"
value={element.fontFamily ?? 'Sora Variable'}
onValueChange={(value) => {
if (value) element.fontFamily = value;
}}
>
<Select.Trigger
id="element-font"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.fontFamily ?? 'Sora Variable'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each fontOptions as font (font.family)}
<Select.Item value={font.family} label={`${font.label} · ${font.category}`} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-font-size" class={label}>Size</Label>
<Input
id="element-font-size"
class={field}
type="number"
min="6"
max="240"
bind:value={element.fontSize}
/>
</div>
<div class="space-y-1">
<Label for="element-font-weight" class={label}>Weight</Label>
<Select.Root
type="single"
value={String(element.fontWeight ?? 400)}
onValueChange={(value) => {
if (value) element.fontWeight = Number(value);
}}
>
<Select.Trigger
id="element-font-weight"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.fontWeight ?? 400}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each [100, 200, 300, 400, 500, 600, 700, 800, 900] as weight (weight)}
<Select.Item value={String(weight)} label={String(weight)} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-align" class={label}>Align</Label>
<Select.Root
type="single"
value={element.align ?? 'left'}
onValueChange={(value) => {
if (value) element.align = value as 'left' | 'center' | 'right';
}}
>
<Select.Trigger
id="element-align"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.align ?? 'left'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="left" label="Left" />
<Select.Item value="center" label="Center" />
<Select.Item value="right" label="Right" />
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-vertical-align" class={label}>Vertical</Label>
<Select.Root
type="single"
value={element.verticalAlign ?? 'top'}
onValueChange={(value) => {
if (value) element.verticalAlign = value as 'top' | 'center' | 'bottom';
}}
>
<Select.Trigger
id="element-vertical-align"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.verticalAlign ?? 'top'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="top" label="Top" />
<Select.Item value="center" label="Middle" />
<Select.Item value="bottom" label="Bottom" />
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-line-height" class={label}>Line height</Label>
<Input
id="element-line-height"
class={field}
type="number"
min="0.7"
max="3"
step="0.05"
bind:value={element.lineHeight}
/>
</div>
<div class="space-y-1">
<Label for="element-letter-spacing" class={label}>Spacing</Label>
<Input
id="element-letter-spacing"
class={field}
type="number"
min="-20"
max="40"
step="0.5"
bind:value={element.letterSpacing}
/>
</div>
</div>
<div class="space-y-1">
<Label for="element-color" class={label}>Color</Label>
<div class="flex items-center gap-2">
<Input
class="h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
type="color"
bind:value={element.color}
/>
<Input id="element-color" class="{field} flex-1 font-mono" bind:value={element.color} />
</div>
</div>
</div>
{:else if element.type === 'image' || element.type === 'video'}
<div class="space-y-3 border-t border-theme-800 pt-3">
<div class="rounded-md border border-theme-800 bg-black/20 p-2">
<p class="text-[10px] uppercase tracking-wide text-muted-foreground">Asset</p>
<p class="mt-1 truncate font-mono text-[10px] text-theme-100">{element.src}</p>
</div>
<Button
variant="secondary"
class="h-8 w-full gap-2 text-xs"
onclick={() => onreplace(element.type as 'image' | 'video')}
>
<RefreshCw class="size-3.5" />
Replace {element.type}
</Button>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-fit" class={label}>Fit</Label>
<Select.Root
type="single"
value={element.fit ?? 'cover'}
onValueChange={(value) => {
if (value) element.fit = value as 'cover' | 'contain';
}}
>
<Select.Trigger
id="element-fit"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.fit ?? 'cover'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="cover" label="Cover" />
<Select.Item value="contain" label="Contain" />
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-radius" class={label}>Radius</Label>
<Input
id="element-radius"
class={field}
type="number"
min="0"
max="999"
bind:value={element.radius}
/>
</div>
</div>
{#if element.type === 'video'}
<div class="flex items-start gap-2 rounded-md border border-theme-800 bg-black/20 p-2">
<Checkbox
id="element-muted"
class="mt-0.5"
checked={element.muted !== false}
onCheckedChange={(checked) => {
element.muted = checked === true;
}}
/>
<div class="space-y-0.5">
<Label for="element-muted" class="text-[10px] font-normal text-theme-100">
Mute video
</Label>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Unmuted videos may wait for a click before they start, because of autoplay policies.
</p>
</div>
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
Videos autoplay and loop. WebM keeps theme packages small; MP4 also works.
</p>
{/if}
</div>
{:else}
<div class="space-y-3 border-t border-theme-800 pt-3">
<div class="space-y-1">
<Label for="element-shape" class={label}>Shape</Label>
<Select.Root
type="single"
value={element.shape}
onValueChange={(value) => {
if (value) element.shape = value as 'rectangle' | 'ellipse' | 'line';
}}
>
<Select.Trigger
id="element-shape"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.shape}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="rectangle" label="Rectangle" />
<Select.Item value="ellipse" label="Ellipse" />
<Select.Item value="line" label="Line" />
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label for="element-fill" class={label}>Fill</Label>
<div class="flex items-center gap-2">
<Input
class="h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5"
type="color"
bind:value={element.fill}
/>
<Input id="element-fill" class="{field} flex-1 font-mono" bind:value={element.fill} />
</div>
</div>
{#if element.shape !== 'ellipse'}
<div class="space-y-1">
<Label for="element-shape-radius" class={label}>Corner radius</Label>
<Input
id="element-shape-radius"
class={field}
type="number"
min="0"
max="999"
bind:value={element.radius}
/>
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-border-width" class={label}>Border</Label>
<Input
id="element-border-width"
class={field}
type="number"
min="0"
max="40"
bind:value={element.borderWidth}
/>
</div>
<div class="space-y-1">
<Label for="element-border-color" class={label}>Border color</Label>
<Input
id="element-border-color"
class="h-8 w-full cursor-pointer border-theme-800 bg-theme-950 p-0.5"
type="color"
bind:value={element.borderColor}
/>
</div>
</div>
</div>
{/if}
</div>
+193
View File
@@ -0,0 +1,193 @@
<script lang="ts">
import Logo from '$assets/logo.png';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import { fontStack } from '$lib/themeFonts';
import { readableTextColor, themeFontFaceCss, type ThemeV2Definition } from '$lib/themeV2';
import { Brush, House, Music, Paintbrush, Play, Settings, User, Wifi } from '@lucide/svelte';
let {
definition,
assets = '',
assetOverrides = {},
selectedId = undefined,
interactive = false,
scale = 1,
onselect,
onpatch,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
selectedId?: string;
interactive?: boolean;
scale?: number;
onselect?: (id: string | undefined) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
} = $props();
const bodyFont = $derived(definition.typography?.body);
const headingFont = $derived(definition.typography?.heading);
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
const accentText = $derived(readableTextColor(accent));
const stageStyle = $derived(
[
`font-family:${fontStack(bodyFont)}`,
`--primary:${accent}`,
`--primary-foreground:${accentText}`,
`--ring:${accent}`,
`--color-primary:${accent}`,
`--color-primary-foreground:${accentText}`,
`--color-primary-500:${accent}`,
`--color-ring:${accent}`,
].join(';')
);
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
const elements = $derived(definition.elements ?? []);
</script>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${fontFaces}</style>`}
<div
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
style={stageStyle}
role="presentation"
onpointerdown={() => {
if (interactive) onselect?.(undefined);
}}
>
<ThemeV2Background {definition} {assets} {assetOverrides} preview />
<div class="relative z-10 grid h-full grid-cols-[0.085fr_1fr]">
<aside
class="flex flex-col items-center gap-2 border-r border-theme-900 bg-black/40 p-3 backdrop-blur-sm"
>
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
</div>
<span
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
>
3.2.7
</span>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary/50 p-3 ring-1 ring-inset ring-white/15"
>
<House class="size-5 text-theme-200" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
>
<Paintbrush class="size-5 text-theme-200" />
</div>
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
>
<Settings class="size-5 text-theme-200" />
</div>
<div
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
>
<User class="size-5 text-muted-foreground" />
</div>
</aside>
<section class="relative flex min-w-0 flex-col">
<div class="flex min-h-0 flex-1 flex-col">
<div class="mt-12 mb-auto flex flex-col px-6">
<span
class="text-4xl font-bold drop-shadow-lg"
style="font-family:{fontStack(headingFont, bodyFont)}"
>
EZPPLauncher
</span>
<span class="font-semibold text-muted-foreground drop-shadow-lg">Hello Guest!</span>
</div>
<div class="relative h-50 shrink-0">
<div
class="absolute left-0 top-0 m-1 flex h-48 w-72 flex-col items-center justify-center gap-3 rounded-md bg-black/40 p-3 ring-1 ring-inset ring-white/5 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-1 text-center">
<span class="text-sm font-semibold text-theme-50">See your stats here</span>
<span class="text-xs text-muted-foreground">
Log in with your EZPPFarm account to track your rank, PP, accuracy and playcount.
</span>
</div>
<div class="flex w-full flex-col gap-2">
<div
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
>
Log In
</div>
<div
class="flex h-7 w-full items-center justify-center rounded-md border border-theme-800 text-xs font-medium text-muted-foreground"
>
Register
</div>
</div>
</div>
</div>
</div>
<div
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-4 backdrop-blur-sm"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Music class="size-3.5 text-blue-400" />
<span class="text-[11px] font-medium text-white/70">1.2k</span>
</div>
<div class="flex items-center gap-2">
<Brush class="size-3.5 text-amber-400" />
<span class="text-[11px] font-medium text-white/70">42</span>
</div>
<div class="flex items-center gap-2">
<Wifi class="size-3.5 text-emerald-400" />
<span class="text-[11px] font-medium text-white/70">19ms</span>
</div>
<div class="h-4 w-px bg-white/10"></div>
<span
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 text-[10px] text-white/50"
>
stable
</span>
<span
class="rounded-full border border-white/6 bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/50"
>
20260101.0
</span>
<span
class="rounded-full border border-primary/20 bg-primary/20 px-2 py-0.5 text-[10px] text-primary"
>
Default skin
</span>
</div>
<div class="flex h-12 items-center rounded-full bg-primary shadow-lg shadow-primary/25">
<div class="relative flex size-12 shrink-0 items-center justify-center">
<div
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
>
<Play class="ml-0.5 size-4 fill-current text-primary-foreground" />
</div>
</div>
<div class="w-52 pl-1 pr-6 text-center">
<p class="text-sm font-bold leading-tight text-primary-foreground">Launch</p>
</div>
</div>
</div>
</section>
</div>
<ThemeV2Elements
{elements}
{assets}
{assetOverrides}
{interactive}
{selectedId}
{scale}
{onselect}
{onpatch}
/>
</div>
@@ -0,0 +1,251 @@
<script lang="ts">
import { custom_theme_volume } from '$lib/global';
import { themeMediaSource } from '$lib/themeMedia';
import type { ThemeV2Definition } from '$lib/themeV2';
import { themeAssetUrl } from '$lib/themeV2';
import { reduceAnimations } from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils';
let {
definition,
assets = '',
assetOverrides = {},
preview = false,
}: {
definition: ThemeV2Definition;
assets?: string;
assetOverrides?: Record<string, string>;
preview?: boolean;
} = $props();
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
const assetUrl = (path: string | undefined) => {
if (!path) return '';
return assetOverrides[path] ?? themeAssetUrl(assets, path);
};
const background = $derived(definition.background);
const overlay = $derived(definition.overlay);
const effects = $derived(definition.effects);
const random = (seed: number) => {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
};
const particles = $derived.by(() => {
if ($reduceAnimations) return [];
if (!effects || effects.type === 'none') return [];
const count = Math.min(120, Math.max(0, Math.round(effects.density ?? 30)));
const speed = Math.max(0.2, effects.speed ?? 1);
return Array.from({ length: count }, (_, index) => {
const value = (offset: number) => random(index + 1 + offset);
const isStar = effects.type === 'stars';
const size = isStar ? 1 + value(1) * 2.5 : 4 + value(1) * 7;
const duration = (isStar ? 2 + value(2) * 3 : 9 + value(2) * 11) / speed;
return {
id: index,
left: value(3) * 100,
top: isStar ? value(4) * 100 : value(4) * 15,
size,
duration,
delay: value(5) * duration,
drift: -90 + value(6) * 180,
opacity: 0.25 + value(7) * 0.65,
rotate: value(8) * 360,
};
});
});
function trackMedia(node: HTMLElement) {
if (preview) return {};
return trackMediaContainer(node);
}
// `muted` is not reliably reflected after load, so keep the live property in
// sync when the theme toggles it in the editor.
function applyMuted(node: HTMLVideoElement, muted: boolean) {
node.muted = muted;
return {
update(value: boolean) {
node.muted = value;
},
};
}
$effect(() => {
if (!audioElement || !definition.audio) return;
audioElement.volume = $custom_theme_volume;
if (!preview) {
audioElement.play().catch(() => {
// Autoplay can be rejected until the window has been interacted with.
});
}
});
</script>
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
{#if background.type === 'color'}
<div
class="absolute inset-0"
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'gradient'}
<div
class="absolute inset-0"
style="background-image:linear-gradient({background.angle ??
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
></div>
{:else if background.type === 'image'}
{#if background.src}
<img
class="absolute inset-0 h-full w-full"
src={assetUrl(background.src)}
alt=""
aria-hidden="true"
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
/>
{/if}
{:else if background.type === 'video'}
{#if background.src}
<video
class="absolute inset-0 h-full w-full object-cover"
use:themeMediaSource={assetUrl(background.src)}
autoplay
loop
muted={background.muted !== false}
use:applyMuted={background.muted !== false}
playsinline
disablepictureinpicture
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
1};filter:{background.blur
? `blur(${background.blur}px)`
: 'none'};transform:{background.blur ? 'scale(1.05)' : 'none'};"
></video>
{/if}
{/if}
{#if overlay && (overlay.opacity ?? 0) > 0}
<div
class="absolute inset-0 pointer-events-none"
style="background-color:{overlay.color ?? '#000000'};opacity:{overlay.opacity ?? 0}"
></div>
{/if}
{#if overlay && (overlay.vignette ?? 0) > 0}
<div
class="vignette absolute inset-0 pointer-events-none"
style="opacity:{overlay.vignette ?? 0}"
></div>
{/if}
{#if effects && effects.type !== 'none'}
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
{#each particles as particle (particle.id)}
<span
class="particle {effects.type}"
style="left:{particle.left}%;top:{particle.top}%;width:{particle.size}px;height:{particle.size}px;opacity:{particle.opacity};--particle-color:{effects.color ??
'#ffffff'};--particle-drift:{particle.drift}px;--particle-duration:{particle.duration}s;--particle-delay:-{particle.delay}s;--particle-rotate:{particle.rotate}deg;animation-duration:{particle.duration}s;animation-delay:-{particle.delay}s;"
></span>
{/each}
</div>
{/if}
{#if definition.audio && !preview}
<audio
bind:this={audioElement}
use:themeMediaSource={assetUrl(definition.audio.src)}
loop={definition.audio.loop !== false}
autoplay
></audio>
{/if}
</div>
<style lang="scss">
.vignette {
background: radial-gradient(ellipse at center, transparent 38%, rgba(0, 0, 0, 0.92) 100%);
}
.particle {
position: absolute;
display: block;
background: var(--particle-color);
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform, opacity;
&.snow {
border-radius: 9999px;
filter: blur(0.5px);
animation-name: v2-snow;
}
&.petals {
border-radius: 70% 0 70% 0;
animation-name: v2-petals;
}
&.bubbles {
border-radius: 9999px;
border: 1px solid var(--particle-color);
background: color-mix(in srgb, var(--particle-color) 16%, transparent);
animation-name: v2-bubbles;
}
&.stars {
border-radius: 9999px;
box-shadow: 0 0 6px var(--particle-color);
animation-name: v2-stars;
animation-timing-function: ease-in-out;
}
}
@keyframes v2-snow {
from {
transform: translate3d(0, -12vh, 0);
}
to {
transform: translate3d(var(--particle-drift), 112vh, 0);
}
}
@keyframes v2-petals {
from {
transform: translate3d(0, -12vh, 0) rotate(var(--particle-rotate));
}
50% {
transform: translate3d(calc(var(--particle-drift) * 0.55), 50vh, 0)
rotate(calc(var(--particle-rotate) + 180deg));
}
to {
transform: translate3d(var(--particle-drift), 112vh, 0)
rotate(calc(var(--particle-rotate) + 360deg));
}
}
@keyframes v2-bubbles {
from {
transform: translate3d(0, 110vh, 0);
}
to {
transform: translate3d(var(--particle-drift), -12vh, 0);
}
}
@keyframes v2-stars {
0%,
100% {
opacity: 0.15;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
</style>
@@ -0,0 +1,244 @@
<script lang="ts">
import { fontStack } from '$lib/themeFonts';
import { themeMediaSource } from '$lib/themeMedia';
import { themeAssetUrl, type ThemeV2Element } from '$lib/themeV2';
let {
elements = [],
assets = '',
assetOverrides = {},
interactive = false,
selectedId = undefined,
scale = 1,
onselect,
onpatch,
}: {
elements?: ThemeV2Element[];
assets?: string;
assetOverrides?: Record<string, string>;
interactive?: boolean;
selectedId?: string;
scale?: number;
onselect?: (id: string | undefined) => void;
onpatch?: (id: string, patch: Record<string, unknown>) => void;
} = $props();
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
// Keep the live muted property in sync, including live editor toggles.
function applyMuted(node: HTMLVideoElement, muted: boolean) {
node.muted = muted;
return {
update(value: boolean) {
node.muted = value;
},
};
}
type DragState = {
id: string;
startX: number;
startY: number;
originX: number;
originY: number;
};
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se';
type ResizeState = DragState & {
direction: ResizeDirection;
originWidth: number;
originHeight: number;
};
const resizeDirections: ResizeDirection[] = ['nw', 'ne', 'sw', 'se'];
let drag = $state<DragState | null>(null);
let resize = $state<ResizeState | null>(null);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function beginDrag(event: PointerEvent, element: ThemeV2Element) {
if (!interactive || (event.target as HTMLElement).dataset.resize) return;
event.preventDefault();
event.stopPropagation();
onselect?.(element.id);
drag = {
id: element.id,
startX: event.clientX,
startY: event.clientY,
originX: element.x,
originY: element.y,
};
window.addEventListener('pointermove', moveDrag);
window.addEventListener('pointerup', endDrag, { once: true });
}
function moveDrag(event: PointerEvent) {
if (!drag) return;
const element = elements.find((candidate) => candidate.id === drag!.id);
if (!element) return;
const dx = (event.clientX - drag.startX) / scale;
const dy = (event.clientY - drag.startY) / scale;
onpatch?.(drag.id, {
x: Math.round(clamp(drag.originX + dx, 0, 1000 - element.width)),
y: Math.round(clamp(drag.originY + dy, 0, 650 - element.height)),
});
}
function endDrag() {
drag = null;
window.removeEventListener('pointermove', moveDrag);
}
function beginResize(
event: PointerEvent,
element: ThemeV2Element,
direction: ResizeState['direction']
) {
if (!interactive) return;
event.preventDefault();
event.stopPropagation();
resize = {
id: element.id,
direction,
startX: event.clientX,
startY: event.clientY,
originX: element.x,
originY: element.y,
originWidth: element.width,
originHeight: element.height,
};
window.addEventListener('pointermove', moveResize);
window.addEventListener('pointerup', endResize, { once: true });
}
function moveResize(event: PointerEvent) {
if (!resize) return;
const dx = (event.clientX - resize.startX) / scale;
const dy = (event.clientY - resize.startY) / scale;
const west = resize.direction.includes('w');
const north = resize.direction.includes('n');
const width = Math.round(clamp(resize.originWidth + (west ? -dx : dx), 24, 1000));
const height = Math.round(clamp(resize.originHeight + (north ? -dy : dy), 24, 650));
onpatch?.(resize.id, {
x: west
? Math.round(clamp(resize.originX + resize.originWidth - width, 0, 1000 - width))
: resize.originX,
y: north
? Math.round(clamp(resize.originY + resize.originHeight - height, 0, 650 - height))
: resize.originY,
width,
height,
});
}
function endResize() {
resize = null;
window.removeEventListener('pointermove', moveResize);
}
</script>
<div
class="absolute inset-0 z-20 {interactive ? 'pointer-events-auto' : 'pointer-events-none'}"
role="presentation"
>
{#each elements as element (element.id)}
{@const isSelected = interactive && element.id === selectedId}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="absolute select-none"
class:cursor-move={interactive}
style="left:{element.x}px;top:{element.y}px;width:{element.width}px;height:{element.height}px;transform:rotate({element.rotation ??
0}deg);opacity:{element.opacity ?? 1};touch-action:none;outline:{isSelected
? '2px solid #ff006e'
: 'none'};outline-offset:2px;"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : -1}
aria-label={interactive ? `Select ${element.type} layer` : undefined}
onpointerdown={(event) => beginDrag(event, element)}
onkeydown={(event) => {
if (interactive && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onselect?.(element.id);
}
}}
>
{#if element.type === 'text'}
<div
class="h-full w-full overflow-hidden"
style="display:flex;align-items:{element.verticalAlign === 'center'
? 'center'
: element.verticalAlign === 'bottom'
? 'flex-end'
: 'flex-start'};justify-content:{element.align === 'center'
? 'center'
: element.align === 'right'
? 'flex-end'
: 'flex-start'};text-align:{element.align ?? 'left'};"
>
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
28}px;font-weight:{element.fontWeight ?? 600};color:{element.color ??
'#ffffff'};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;"
>
{element.text}
</span>
</div>
{:else if element.type === 'image'}
<img
class="pointer-events-none h-full w-full"
src={assetUrl(element.src)}
alt=""
draggable="false"
style="object-fit:{element.fit ?? 'cover'};border-radius:{element.radius ?? 0}px;"
/>
{:else if element.type === 'video'}
<video
class="pointer-events-none h-full w-full"
use:themeMediaSource={assetUrl(element.src)}
autoplay
loop
muted={element.muted !== false}
use:applyMuted={element.muted !== false}
playsinline
disablepictureinpicture
aria-hidden="true"
style="object-fit:{element.fit ?? 'cover'};border-radius:{element.radius ?? 0}px;"
></video>
{:else}
<div
class="h-full w-full"
style="background:{element.fill ?? '#ff006e'};border-radius:{element.shape === 'ellipse'
? '50%'
: `${element.radius ?? 16}px`};border:{element.borderWidth
? `${element.borderWidth}px solid ${element.borderColor ?? '#ffffff'}`
: 'none'};"
></div>
{/if}
{#if isSelected}
{#each resizeDirections as direction (direction)}
<span
class="absolute z-10 block size-3 rounded-full border-2 border-white bg-primary shadow"
data-resize={direction}
style="{direction.includes('n') ? 'top:-7px' : 'bottom:-7px'};{direction.includes('w')
? 'left:-7px'
: 'right:-7px'};cursor:{direction === 'nw' || direction === 'se'
? 'nwse-resize'
: 'nesw-resize'};"
onpointerdown={(event) => beginResize(event, element, direction)}
role="presentation"
></span>
{/each}
<span
class="absolute -top-7 left-0 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground shadow"
>
{element.type}
</span>
{/if}
</div>
{/each}
</div>
@@ -1,10 +0,0 @@
<script lang="ts">
const prop: { beatmapId: number } = $props();
</script>
<div class="absolute w-screen h-screen overflow-hidden pointer-events-none rounded">
<div
style="background: url(https://osu.direct/api/media/background/{prop.beatmapId})"
class="absolute top-0 left-0 w-full h-full !bg-cover -z-10 pointer-events-none blur opacity-10 rounded"
></div>
</div>
@@ -53,6 +53,8 @@
</script>
{#if href}
<!-- href is a pass-through prop and may point to an external URL -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
{@render children?.()}
</a>
-106
View File
@@ -1,106 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { Heart } from '@lucide/svelte';
interface Sparkle {
id: string;
x: string;
y: string;
color: string;
delay: number;
scale: number;
}
let {
className,
colors = {
first: '#9E7AFF',
second: '#FE8BBB',
},
count = 10,
}: {
className: string;
colors?: { first: string; second: string };
count?: number;
} = $props();
// Helper to check if two sparkles are too close
function isTooClose(x1: number, y1: number, x2: number, y2: number, minDist: number) {
const dx = x1 - x2;
const dy = y1 - y2;
return Math.sqrt(dx * dx + dy * dy) < minDist;
}
// Generate sparkles with minimum distance between them
let sparkles: Sparkle[] = [];
const minDistance = 25; // in percentage points (adjust as needed)
let attempts = 0;
while (sparkles.length < count && attempts < count * 20) {
attempts++;
let starX = Math.random() * 100;
let starY = Math.random() * 100;
let tooClose = sparkles.some((s) =>
isTooClose(Number.parseFloat(s.x), Number.parseFloat(s.y), starX, starY, minDistance)
);
if (tooClose) continue;
let color = Math.random() > 0.5 ? colors.first : colors.second;
let delay = Math.random() * 3;
let scale = Math.random() * 1 + 0.3;
let id = `${starX}-${starY}-${Date.now()}-${Math.random()}`;
sparkles.push({
id,
x: `${starX}%`,
y: `${starY}%`,
color,
delay,
scale,
});
}
</script>
<div class={cn('relative', className)}>
<span class={cn('relative inline-block', className)}>
{#each sparkles as item (item.id)}
<div
class="sparkle pointer-events-none absolute z-20 top-0 left-0"
id={item.id}
style="left: {item.x}; top: {item.y}; color: {item.color}; animation-delay: {item.delay}s; transform: scale({item.scale});"
>
<!-- <svg width="13" height="13" viewBox="0 0 21 21">
<path
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
fill={item.color}
/>
</svg> -->
<Heart fill={item.color} size={14} />
</div>
{/each}
</span>
</div>
<style lang="postcss">
.sparkle {
animation: sparkle-fade 1.2s linear infinite;
opacity: 0;
}
@keyframes sparkle-fade {
0% {
opacity: 0;
transform: scale(0) rotate(-50deg);
}
50% {
opacity: 1;
}
80% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0) rotate(50deg);
}
}
</style>
@@ -0,0 +1,48 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Slider as SliderPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(0),
min = 0,
max = 100,
step = 1,
disabled = false,
class: className,
onValueChange,
onValueCommit,
}: {
ref?: HTMLSpanElement | null;
value?: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
class?: string;
onValueChange?: (value: number) => void;
onValueCommit?: (value: number) => void;
} = $props();
</script>
<SliderPrimitive.Root
bind:ref
type="single"
bind:value
{min}
{max}
{step}
{disabled}
{onValueChange}
{onValueCommit}
class={cn(
'bg-primary/20 relative flex h-1.5 w-full touch-none select-none items-center rounded-full',
className
)}
>
<SliderPrimitive.Range class="bg-primary absolute h-full rounded-full" />
<SliderPrimitive.Thumb
index={0}
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-4 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
</SliderPrimitive.Root>
-1
View File
@@ -1 +0,0 @@
export { default as Toaster } from './sonner.svelte';
@@ -1,21 +0,0 @@
<script lang="ts">
import { mode } from 'mode-watcher';
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group !z-[1000]"
toastOptions={{
classes: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...restProps}
/>
-7
View File
@@ -1,7 +0,0 @@
import Root from './switch.svelte';
export {
Root,
//
Root as Switch,
};
@@ -1,27 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
'focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
'bg-background pointer-events-none block size-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
@@ -0,0 +1,22 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLTextareaAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithElementRef<HTMLTextareaAttributes> = $props();
</script>
<textarea
bind:this={ref}
class={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-20 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...restProps}
></textarea>
@@ -1,6 +1,6 @@
<script lang="ts">
import { config } from '$lib/config';
import { custom_theme_audio_playing, custom_theme_volume } from '$lib/global';
import { custom_theme_audio_playing, custom_theme_volume, platform } from '$lib/global';
import { reduceAnimations } from '$lib/userSettings';
import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
import Minimize from '@lucide/svelte/icons/minus';
@@ -19,7 +19,7 @@
});
</script>
<div data-tauri-drag-region class="titlebar z-[100]">
<div data-tauri-drag-region class="titlebar z-[100]" class:macos-window={$platform === 'macos'}>
{#if $custom_theme_audio_playing}
{#if $custom_theme_audio_playing}
<div class="relative flex items-center justify-center group">
@@ -115,6 +115,11 @@
pointer-events: auto;
}
/* Keep the window controls clear of the rounded top-right corner. */
.titlebar.macos-window {
padding-right: 12px;
}
.volume-slider {
appearance: none;
-webkit-appearance: none;
+154
View File
@@ -0,0 +1,154 @@
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
const FONTSOURCE_API = 'https://api.fontsource.org/v1/fonts';
const FONTSOURCE_CDN = 'https://cdn.jsdelivr.net/fontsource/fonts';
export type FontsourceFont = {
id: string;
family: string;
subsets: string[];
weights: number[];
styles: string[];
defSubset: string;
variable: boolean;
category: string;
type: string;
};
export type FontsourceFontOptions = {
weight?: number;
style: 'normal' | 'italic';
subset: string;
};
let catalogPromise: Promise<FontsourceFont[]> | undefined;
const isFontsourceFont = (value: unknown): value is FontsourceFont => {
if (!value || typeof value !== 'object') return false;
const font = value as Record<string, unknown>;
return (
typeof font.id === 'string' &&
/^[a-z0-9-]+$/i.test(font.id) &&
typeof font.family === 'string' &&
font.family.trim().length > 0 &&
Array.isArray(font.subsets) &&
font.subsets.every((subset) => typeof subset === 'string' && /^[a-z0-9-]+$/i.test(subset)) &&
Array.isArray(font.weights) &&
font.weights.every((weight) => typeof weight === 'number') &&
Array.isArray(font.styles) &&
font.styles.every((style) => style === 'normal' || style === 'italic') &&
typeof font.defSubset === 'string' &&
typeof font.variable === 'boolean'
);
};
/** Load the complete Fontsource catalog once per editor session. */
export const getFontsourceCatalog = async (): Promise<FontsourceFont[]> => {
if (!catalogPromise) {
catalogPromise = fetch(FONTSOURCE_API, { headers: { Accept: 'application/json' } })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Fontsource returned ${response.status}`);
}
const payload = (await response.json()) as unknown;
if (!Array.isArray(payload)) {
throw new Error('Fontsource returned an unexpected catalog.');
}
return payload
.filter(isFontsourceFont)
.map((font) => ({
...font,
category: typeof font.category === 'string' ? font.category : 'other',
type: typeof font.type === 'string' ? font.type : 'google',
}))
.sort((left, right) => left.family.localeCompare(right.family));
})
.catch((error) => {
catalogPromise = undefined;
throw error;
});
}
return catalogPromise;
};
const normalizeStyle = (font: FontsourceFont, style: string) =>
style === 'italic' && font.styles.includes('italic') ? 'italic' : 'normal';
const normalizeSubset = (font: FontsourceFont, subset: string) =>
font.subsets.includes(subset)
? subset
: font.subsets.includes(font.defSubset)
? font.defSubset
: (font.subsets[0] ?? 'latin');
const normalizeWeight = (font: FontsourceFont, weight: number | undefined) => {
const preferred = weight ?? 400;
return font.weights.includes(preferred) ? preferred : (font.weights[0] ?? 400);
};
/** Direct jsDelivr URL for a single Fontsource WOFF2 file. */
export const fontsourceFontUrl = (font: FontsourceFont, options: FontsourceFontOptions) => {
const subset = normalizeSubset(font, options.subset);
const style = normalizeStyle(font, options.style);
const family = font.variable ? `${font.id}:vf` : font.id;
const weight = font.variable ? 'wght' : String(normalizeWeight(font, options.weight));
return `${FONTSOURCE_CDN}/${family}@latest/${subset}-${weight}-${style}.woff2`;
};
/** Stable local filename used inside the editor cache and theme assets. */
export const fontsourceAssetName = (font: FontsourceFont, options: FontsourceFontOptions) => {
const subset = normalizeSubset(font, options.subset);
const style = normalizeStyle(font, options.style);
const weight = font.variable ? 'variable' : String(normalizeWeight(font, options.weight));
return `font_${font.id.replace(/[^a-z0-9]+/gi, '_')}_${subset}_${weight}_${style}.woff2`;
};
/** Key used to avoid duplicate family/weight/style entries in a theme. */
export const themeFontKey = (family: string, weight: string, style: string) =>
`${family.trim().toLowerCase()}|${weight.trim()}|${style}`;
export const fontsourceFontKey = (font: FontsourceFont, options: FontsourceFontOptions) => {
const weight = font.variable ? '100 900' : String(normalizeWeight(font, options.weight));
return themeFontKey(font.family, weight, normalizeStyle(font, options.style));
};
/** Sensible face to use when previewing a catalog family as a whole. */
export const defaultFontsourceOptions = (font: FontsourceFont): FontsourceFontOptions => ({
weight: normalizeWeight(font, 400),
style: normalizeStyle(font, 'normal'),
subset: normalizeSubset(font, font.defSubset),
});
export const fontCacheDirectory = async () =>
await path.join(await path.homeDir(), '.ezpplauncher', 'font-cache');
/**
* Download a selected Fontsource face into the editor cache. The cached file is
* later copied into the theme's assets folder when the theme is saved.
*/
export const downloadFontsourceFont = async (
font: FontsourceFont,
options: FontsourceFontOptions
): Promise<{ name: string; source: string; url: string }> => {
const url = fontsourceFontUrl(font, options);
const name = fontsourceAssetName(font, options);
const directory = await fontCacheDirectory();
const target = await path.join(directory, name);
await fs.mkdir(directory, { recursive: true });
if (!(await fs.exists(target))) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Could not download ${font.family} (${response.status}).`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength === 0) {
throw new Error(`Fontsource returned an empty file for ${font.family}.`);
}
await fs.writeFile(target, bytes);
}
return { name, source: target, url };
};
+98
View File
@@ -0,0 +1,98 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Forward webview console output and uncaught errors to the launcher log file.
*
* Tauri's log plugin only captures Rust logs, so the frontend mirrors console
* calls through a small command. The original console methods are preserved so
* devtools output and HMR are unaffected.
*/
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const MAX_MESSAGE_LENGTH = 16_000;
const INITIALIZED_KEY = '__ezppLauncherLoggingInitialized';
const originalConsole = {
debug: console.debug.bind(console),
info: console.info.bind(console),
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
function stringify(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Error) {
return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ''}`;
}
if (typeof value === 'object' && value !== null) {
try {
const seen = new WeakSet<object>();
return JSON.stringify(value, (_key, candidate: unknown) => {
if (typeof candidate === 'bigint') return candidate.toString();
if (typeof candidate === 'object' && candidate !== null) {
if (seen.has(candidate)) return '[Circular]';
seen.add(candidate);
}
return candidate;
});
} catch {
return String(value);
}
}
return String(value);
}
function send(level: LogLevel, values: unknown[]) {
const message = values.map(stringify).join(' ').slice(0, MAX_MESSAGE_LENGTH);
// Never let a failed IPC call produce another console error and loop back.
invoke('log_frontend', { level, message }).catch((error) => {
originalConsole.error('Could not forward a log message:', error);
});
}
export function initLogging() {
if (typeof window === 'undefined') return;
const marker = window as unknown as Record<string, boolean>;
if (marker[INITIALIZED_KEY]) return;
marker[INITIALIZED_KEY] = true;
console.debug = (...values: unknown[]) => {
originalConsole.debug(...values);
send('debug', values);
};
console.info = (...values: unknown[]) => {
originalConsole.info(...values);
send('info', values);
};
console.log = (...values: unknown[]) => {
originalConsole.log(...values);
send('info', values);
};
console.warn = (...values: unknown[]) => {
originalConsole.warn(...values);
send('warn', values);
};
console.error = (...values: unknown[]) => {
originalConsole.error(...values);
send('error', values);
};
window.addEventListener('error', (event) => {
send('error', [
`[uncaught] ${event.message}`,
event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '',
event.error instanceof Error ? event.error.stack : '',
]);
});
window.addEventListener('unhandledrejection', (event) => {
send('error', ['[unhandled rejection]', event.reason]);
});
send('info', [`Webview started: ${window.location.href}`]);
}
+27
View File
@@ -2,6 +2,7 @@ import type { UpdateFile, UpdateStatus } from './types';
import { betterFetch } from '@better-fetch/fetch';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
const updateUrl = 'https://ez-pp.farm/api/ezpplauncher';
@@ -23,9 +24,32 @@ export const getHWID = async (): Promise<string | undefined> =>
export const isValidOsuFolder = async (folder: string): Promise<boolean | undefined> =>
await invoke<boolean | undefined>('valid_osu_folder', { folder });
export const resolveOsuFolder = async (folder: string): Promise<string | undefined> =>
(await invoke<string | null>('resolve_osu_folder', { folder })) ?? undefined;
export const autoDetectOsuInstallFolder = async () =>
await invoke<string | undefined>('find_osu_installation');
/**
* Pick an osu! installation. On macOS the app runs inside a Wineskin `.app`
* bundle, and the standard dialog would descend into the bundle instead of
* letting it be selected, so a native panel is used there.
*/
export const pickOsuInstallation = async (platform: string): Promise<string | undefined> => {
if (platform === 'macos') {
return (await invoke<string | null>('pick_osu_installation')) ?? undefined;
}
const selected = await open({
directory: true,
multiple: false,
title: 'Select osu! Installation Folder',
canCreateDirectories: false,
});
return typeof selected === 'string' ? selected : undefined;
};
export const setUserConfigValues = async (
osuFolderPath: string,
entries: { key: string; value: string }[]
@@ -120,6 +144,9 @@ export const isOsuRunning = async (): Promise<boolean> => await invoke<boolean>(
export const getLauncherVersion = async () => await invoke<string>('get_launcher_version');
export const exit = async () => await invoke('exit');
export const getPlatform = async (): Promise<string> => await invoke<string>('get_platform');
export const setLoggingEnabled = async (enabled: boolean) =>
await invoke('set_logging_enabled', { enabled });
export const openLogsFolder = async () => await invoke('open_logs_folder');
export const isOsuCorrupted = async (folder: string): Promise<boolean> =>
await invoke<boolean>('check_for_corruption', { folder });
export const hasWMCTRL = async (): Promise<boolean> => await invoke<boolean>('has_wmctrl');
-24
View File
@@ -1,24 +0,0 @@
const specialOrder = "'-!#$%&(),;@[]^_`{}~+=";
const priority = Object.fromEntries([...specialOrder].map((c, i) => [c, i]));
const osuSkinsCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export const sortSkins = (
skins: { name: string; author: string | undefined; modified: number }[]
) =>
skins.sort((a, b) => {
const pa = priority[a.name[0]];
const pb = priority[b.name[0]];
if (pa !== undefined || pb !== undefined) {
if (pa === undefined) return 1;
if (pb === undefined) return -1;
return pa - pb;
}
return osuSkinsCollator.compare(a.name, b.name);
});
+18
View File
@@ -0,0 +1,18 @@
export type ThemeFontChoice = {
label: string;
family: string;
category: 'Sans' | 'Serif' | 'Display' | 'Mono' | 'Custom';
};
/** Fonts bundled with the launcher through @fontsource. */
export const BUNDLED_THEME_FONTS: ThemeFontChoice[] = [
{ label: 'Sora', family: 'Sora Variable', category: 'Sans' },
{ label: 'Montserrat', family: 'Montserrat Variable', category: 'Sans' },
{ label: 'Bebas Neue', family: 'Bebas Neue', category: 'Display' },
{ label: 'Playfair Display', family: 'Playfair Display Variable', category: 'Serif' },
{ label: 'Space Mono', family: 'Space Mono', category: 'Mono' },
{ label: 'JetBrains Mono', family: 'JetBrains Mono Variable', category: 'Mono' },
];
export const fontStack = (family: string | undefined, fallback = 'Sora Variable') =>
`"${(family || fallback).replace(/["\\]/g, '\\$&')}", ui-sans-serif, system-ui, sans-serif`;
+208
View File
@@ -0,0 +1,208 @@
/**
* Theme media playback helper.
*
* On macOS, WKWebView's media pipeline asks Tauri's custom `asset:` protocol
* for thousands of one-byte ranges when it plays a WebM file. That request
* storm starves the audio decoder, which makes the audio stutter or drop out.
* Fetching the file once and handing the media element a `blob:` URL avoids
* the custom protocol entirely and lets WebKit decode from a local buffer.
*/
type MediaCleanup = () => void;
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
// The request-storm problem is specific to WKWebView. Keep the native
// streaming path everywhere else, where the asset protocol behaves correctly
// (and where blob-backed media can fail to decode in WebKitGTK).
const useBlobMedia =
typeof navigator !== 'undefined' && /Macintosh|Mac OS X/i.test(navigator.userAgent);
const blobUrlPromises = new Map<string, Promise<string>>();
const objectUrls = new Set<string>();
const mediaTokens = new WeakMap<HTMLMediaElement, number>();
const mediaCleanups = new WeakMap<HTMLMediaElement, MediaCleanup>();
let tokenCounter = 0;
let cacheGeneration = 0;
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
!!url && assetPattern.test(url);
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
export const themeMediaUrl = async (source: string): Promise<string> => {
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
const cached = blobUrlPromises.get(source);
if (cached) return cached;
const generation = cacheGeneration;
const promise = (async () => {
try {
const response = await fetch(source);
if (!response.ok) throw new Error(`Theme media request failed (${response.status})`);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
// The theme was switched while the file was loading; don't keep a URL
// that nothing can use anymore.
if (generation !== cacheGeneration) {
URL.revokeObjectURL(objectUrl);
return source;
}
objectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
console.warn('Could not prepare theme media for smooth playback', error);
return source;
}
})();
blobUrlPromises.set(source, promise);
return promise;
};
/** Revoke URLs from the previous theme. */
export const clearThemeMediaCache = () => {
cacheGeneration += 1;
blobUrlPromises.clear();
for (const url of objectUrls) URL.revokeObjectURL(url);
objectUrls.clear();
};
/**
* Swap a media element over to a blob URL while keeping its playback position
* and state. `source` must be the original asset URL, not the blob URL.
*/
const loadMediaSource = (node: HTMLMediaElement, source: string) => {
const token = ++tokenCounter;
mediaTokens.set(node, token);
mediaCleanups.get(node)?.();
mediaCleanups.delete(node);
void themeMediaUrl(source).then((url) => {
if (mediaTokens.get(node) !== token || !node.isConnected) return;
if (node.src === url) return;
const wasPlaying = !node.paused;
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
const cleanup = () => {
node.removeEventListener('loadedmetadata', onLoaded);
node.removeEventListener('error', onError);
};
const onLoaded = () => {
cleanup();
mediaCleanups.delete(node);
if (previousTime > 0.05) {
try {
node.currentTime = previousTime;
} catch {
// Some media formats cannot seek until more data is buffered.
}
}
if (wasPlaying || node.autoplay) {
void node.play().catch(() => {
// Autoplay can be rejected until the user interacts with the window.
});
}
};
const onError = () => {
cleanup();
mediaCleanups.delete(node);
// Keep the theme usable if this WebView cannot decode the blob.
if (isThemeAssetUrl(source) && node.src !== source) {
node.src = source;
node.load();
if (wasPlaying || node.autoplay) void node.play().catch(() => {});
}
};
node.addEventListener('loadedmetadata', onLoaded);
node.addEventListener('error', onError);
mediaCleanups.set(node, cleanup);
node.src = url;
node.load();
});
};
/**
* Svelte action for v2 theme videos/audio. Use this instead of `src=...`.
*/
export function themeMediaSource(node: HTMLMediaElement, source: string) {
let currentSource = source;
loadMediaSource(node, currentSource);
return {
update(nextSource: string) {
if (nextSource === currentSource) return;
currentSource = nextSource;
loadMediaSource(node, currentSource);
},
destroy() {
mediaTokens.delete(node);
mediaCleanups.get(node)?.();
mediaCleanups.delete(node);
},
};
}
/**
* Svelte action for the v1 theme container. Themes mount their own markup, so
* watch for media elements (and src changes) and route them through blobs too.
*/
export function smoothThemeMedia(node: HTMLElement) {
const lastSource = new WeakMap<HTMLMediaElement, string>();
const prepare = (media: HTMLMediaElement) => {
const source =
media.getAttribute('src') ??
media.querySelector<HTMLSourceElement>('source[src]')?.getAttribute('src') ??
undefined;
if (!isThemeAssetUrl(source)) return;
if (lastSource.get(media) === source) return;
lastSource.set(media, source);
loadMediaSource(media, source);
};
const scan = (root: ParentNode) => {
if (root instanceof HTMLMediaElement) prepare(root);
root.querySelectorAll?.<HTMLMediaElement>('video, audio').forEach(prepare);
};
scan(node);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.target instanceof HTMLMediaElement) {
prepare(mutation.target);
continue;
}
for (const added of mutation.addedNodes) {
if (added instanceof HTMLElement) scan(added);
}
}
});
observer.observe(node, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src'],
});
return {
destroy() {
observer.disconnect();
},
};
}
File diff suppressed because it is too large. Load diff
+567
View File
@@ -0,0 +1,567 @@
/**
* Declarative theme API 2.0.
*
* Version 1 themes are JavaScript bundles that mount their own UI. Version 2
* themes describe their look with data instead, which lets the launcher render
* them consistently and lets the built-in editor create them without shipping
* any code. A v2 theme package is a zip containing `theme.json` and an
* `assets/` folder.
*/
export type ThemeV2BackgroundType = 'color' | 'gradient' | 'image' | 'video';
export type ThemeV2Background =
| {
type: 'color';
color: string;
opacity?: number;
}
| {
type: 'gradient';
from: string;
to: string;
angle?: number;
opacity?: number;
}
| {
type: 'image';
src: string;
fit?: 'cover' | 'contain';
opacity?: number;
blur?: number;
}
| {
type: 'video';
src: string;
fit?: 'cover' | 'contain';
opacity?: number;
blur?: number;
/** Defaults to true so autoplay works without user interaction. */
muted?: boolean;
};
export type ThemeV2EffectType = 'none' | 'snow' | 'stars' | 'petals' | 'bubbles';
export type ThemeV2Effects = {
type: ThemeV2EffectType;
density?: number;
speed?: number;
color?: string;
};
export type ThemeV2Overlay = {
color?: string;
opacity?: number;
vignette?: number;
};
export type ThemeV2Audio = {
src: string;
loop?: boolean;
};
export type ThemeV2Font = {
/** The family name used by elements and the typography settings. */
family: string;
/** Font file relative to the theme's assets folder. */
src: string;
weight?: string;
style?: 'normal' | 'italic';
};
export type ThemeV2Typography = {
/** Font used by the launcher's normal UI text. */
body?: string;
/** Font used by headings such as the launcher title. */
heading?: string;
};
export type ThemeV2ElementAlign = 'left' | 'center' | 'right';
export type ThemeV2ElementVerticalAlign = 'top' | 'center' | 'bottom';
export type ThemeV2ElementBase = {
id: string;
/** Stage coordinates: the launcher canvas is 1000 x 650. */
x: number;
y: number;
width: number;
height: number;
rotation?: number;
opacity?: number;
};
export type ThemeV2TextElement = ThemeV2ElementBase & {
type: 'text';
text: string;
fontFamily?: string;
fontSize?: number;
fontWeight?: number;
color?: string;
align?: ThemeV2ElementAlign;
verticalAlign?: ThemeV2ElementVerticalAlign;
lineHeight?: number;
letterSpacing?: number;
};
export type ThemeV2ImageElement = ThemeV2ElementBase & {
type: 'image';
src: string;
fit?: 'cover' | 'contain';
radius?: number;
};
export type ThemeV2VideoElement = ThemeV2ElementBase & {
type: 'video';
src: string;
fit?: 'cover' | 'contain';
radius?: number;
/** Defaults to true so autoplay works without user interaction. */
muted?: boolean;
};
export type ThemeV2ShapeElement = ThemeV2ElementBase & {
type: 'shape';
shape: 'rectangle' | 'ellipse' | 'line';
fill?: string;
radius?: number;
borderWidth?: number;
borderColor?: string;
};
export type ThemeV2Element =
| ThemeV2TextElement
| ThemeV2ImageElement
| ThemeV2VideoElement
| ThemeV2ShapeElement;
export type ThemeV2Definition = {
name: string;
version: string;
apiVersion: '2.0';
author: string;
description?: string;
/** Marks the theme as adult content in the theme manager. */
nsfw?: boolean;
/** Optional preview image, relative to the theme's assets folder. */
preview?: string;
background: ThemeV2Background;
overlay: ThemeV2Overlay;
effects: ThemeV2Effects;
audio?: ThemeV2Audio;
/** Theme-wide fonts and freeform layers rendered over the launcher UI. */
typography: ThemeV2Typography;
fonts: ThemeV2Font[];
elements: ThemeV2Element[];
/** Used for focus rings, active states and effect accents. */
accentColor?: string;
/** Optional CSS appended after the launcher's own styles. */
css?: string;
};
export type ThemeV2Asset = {
name: string;
source: string;
};
const clamp = (value: unknown, fallback: number, min: number, max: number) => {
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
};
const text = (value: unknown, fallback = '') =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback;
const color = (value: unknown, fallback: string) => {
const result = text(value, fallback);
return /^#(?:[0-9a-f]{3,8})$/i.test(result) ? result : fallback;
};
const oneOf = <T extends string>(value: unknown, options: readonly T[], fallback: T): T =>
options.includes(value as T) ? (value as T) : fallback;
const clampInteger = (value: unknown, fallback: number, min: number, max: number) =>
Math.round(clamp(value, fallback, min, max));
const fontFamilyCss = (family: string) => `"${family.replace(/["\\]/g, '\\$&')}"`;
const fontFormat = (src: string) => {
const extension = src.split('.').pop()?.toLowerCase();
if (extension === 'woff2') return 'woff2';
if (extension === 'woff') return 'woff';
if (extension === 'otf') return 'opentype';
if (extension === 'ttf') return 'truetype';
return 'woff2';
};
const normalizeFonts = (raw: unknown): ThemeV2Font[] => {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const fonts: ThemeV2Font[] = [];
for (const candidate of raw.slice(0, 48)) {
if (!candidate || typeof candidate !== 'object') continue;
const source = candidate as Record<string, unknown>;
const family = text(source.family).slice(0, 80);
const src = text(source.src).slice(0, 260);
const weight =
text(typeof source.weight === 'number' ? String(source.weight) : source.weight, '400')
.replace(/[^0-9,\s]/g, '')
.slice(0, 40) || '400';
const style = source.style === 'italic' ? 'italic' : 'normal';
const key = `${family}|${weight}|${style}|${src}`;
if (!family || !src || seen.has(key)) continue;
seen.add(key);
fonts.push({ family, src, weight, style });
}
return fonts;
};
const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefined => {
if (!raw || typeof raw !== 'object') return undefined;
const source = raw as Record<string, unknown>;
const type = oneOf(source.type, ['text', 'image', 'video', 'shape'] as const, 'text');
const base = {
id: text(source.id, `element_${index + 1}`).slice(0, 64),
x: clamp(source.x, 0, 0, 1000),
y: clamp(source.y, 0, 0, 650),
width: clamp(source.width, 320, 24, 1000),
height: clamp(source.height, 120, 24, 650),
rotation: clamp(source.rotation, 0, -360, 360),
opacity: clamp(source.opacity, 1, 0, 1),
};
if (type === 'text') {
return {
...base,
type,
text: typeof source.text === 'string' ? source.text.slice(0, 2000) : 'Your text',
fontFamily: text(source.fontFamily, 'Sora Variable'),
fontSize: clamp(source.fontSize, 28, 6, 240),
fontWeight: clampInteger(source.fontWeight, 600, 100, 900),
color: color(source.color, '#ffffff'),
align: oneOf(source.align, ['left', 'center', 'right'] as const, 'left'),
verticalAlign: oneOf(source.verticalAlign, ['top', 'center', 'bottom'] as const, 'top'),
lineHeight: clamp(source.lineHeight, 1.2, 0.7, 3),
letterSpacing: clamp(source.letterSpacing, 0, -20, 40),
};
}
if (type === 'shape') {
return {
...base,
type,
shape: oneOf(source.shape, ['rectangle', 'ellipse', 'line'] as const, 'rectangle'),
fill: color(source.fill, '#ff006e'),
radius: clamp(source.radius, 16, 0, 999),
borderWidth: clamp(source.borderWidth, 0, 0, 40),
borderColor: color(source.borderColor, '#ffffff'),
};
}
const src = text(source.src).slice(0, 500);
if (!src) return undefined;
return {
...base,
type,
src,
fit: source.fit === 'contain' ? 'contain' : 'cover',
radius: clamp(source.radius, 0, 0, 999),
...(type === 'video' ? { muted: source.muted !== false } : {}),
};
};
const normalizeElements = (raw: unknown): ThemeV2Element[] => {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const elements: ThemeV2Element[] = [];
raw.slice(0, 100).forEach((candidate, index) => {
const element = normalizeElement(candidate, index);
if (!element) return;
let id = element.id;
let suffix = 2;
while (seen.has(id)) id = `${element.id}_${suffix++}`;
seen.add(id);
elements.push({ ...element, id });
});
return elements;
};
/** Create a sensible element for the advanced editor's add buttons. */
export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): ThemeV2Element => {
const id = `element_${Date.now().toString(36)}_${index}`;
const base = { id, x: 340, y: 250, width: 320, height: 120 };
if (type === 'image') {
return normalizeElement(
{ ...base, type, src: 'placeholder.png' },
index
)! as ThemeV2ImageElement;
}
if (type === 'video') {
return normalizeElement(
{ ...base, type, src: 'placeholder.webm' },
index
)! as ThemeV2VideoElement;
}
if (type === 'shape') {
return normalizeElement(
{ ...base, type, shape: 'rectangle', width: 220, height: 120 },
index
)! as ThemeV2ShapeElement;
}
return normalizeElement(
{ ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 },
index
)! as ThemeV2TextElement;
};
export const isThemeV2 = (apiVersion: unknown) =>
typeof apiVersion === 'string' && apiVersion.split('.')[0] === '2';
export const createDefaultThemeV2 = (): ThemeV2Definition => ({
name: 'Untitled theme',
version: '1.0.0',
apiVersion: '2.0',
author: '',
description: '',
nsfw: false,
background: {
type: 'gradient',
from: '#181825',
to: '#3b1d4f',
angle: 135,
opacity: 1,
},
overlay: {
color: '#000000',
opacity: 0.2,
vignette: 0.35,
},
effects: {
type: 'none',
density: 30,
speed: 1,
color: '#ffffff',
},
typography: {
body: 'Sora Variable',
heading: 'Sora Variable',
},
fonts: [],
elements: [],
accentColor: '#ff006e',
css: '',
});
/** Coerce a theme.json payload into the supported v2 shape. */
export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Definition => {
const defaults = createDefaultThemeV2();
const sourceBackground = (raw.background ?? {}) as Record<string, unknown>;
const backgroundType = ['color', 'gradient', 'image', 'video'].includes(
String(sourceBackground.type)
)
? (String(sourceBackground.type) as ThemeV2BackgroundType)
: 'gradient';
let background: ThemeV2Background;
switch (backgroundType) {
case 'color':
background = {
type: 'color',
color: color(sourceBackground.color, '#181825'),
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
};
break;
case 'image':
background = {
type: 'image',
src: text(sourceBackground.src),
fit: sourceBackground.fit === 'contain' ? 'contain' : 'cover',
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
blur: clamp(sourceBackground.blur, 0, 0, 40),
};
break;
case 'video':
background = {
type: 'video',
src: text(sourceBackground.src),
fit: sourceBackground.fit === 'contain' ? 'contain' : 'cover',
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
blur: clamp(sourceBackground.blur, 0, 0, 40),
muted: sourceBackground.muted !== false,
};
break;
default:
background = {
type: 'gradient',
from: color(
sourceBackground.from,
defaults.background.type === 'gradient' ? defaults.background.from : '#181825'
),
to: color(sourceBackground.to, '#3b1d4f'),
angle: clamp(sourceBackground.angle, 135, 0, 360),
opacity: clamp(sourceBackground.opacity, 1, 0, 1),
};
}
const sourceEffects = (raw.effects ?? {}) as Record<string, unknown>;
const effectType = ['none', 'snow', 'stars', 'petals', 'bubbles'].includes(
String(sourceEffects.type)
)
? (String(sourceEffects.type) as ThemeV2EffectType)
: 'none';
const sourceOverlay = (raw.overlay ?? {}) as Record<string, unknown>;
const sourceAudio = raw.audio as Record<string, unknown> | undefined;
const sourceTypography = (raw.typography ?? {}) as Record<string, unknown>;
const bodyFont = text(sourceTypography.body).slice(0, 80);
const headingFont = text(sourceTypography.heading).slice(0, 80);
return {
name: text(raw.name, 'Untitled theme'),
version: text(raw.version, '1.0.0'),
apiVersion: '2.0',
author: text(raw.author, 'Unknown'),
description: text(raw.description),
nsfw: raw.nsfw === true,
preview: text(raw.preview),
background,
overlay: {
color: color(sourceOverlay.color, '#000000'),
opacity: clamp(sourceOverlay.opacity, 0.2, 0, 1),
vignette: clamp(sourceOverlay.vignette, 0.35, 0, 1),
},
effects: {
type: effectType,
density: Math.round(clamp(sourceEffects.density, 30, 0, 120)),
speed: clamp(sourceEffects.speed, 1, 0.2, 3),
color: color(sourceEffects.color, '#ffffff'),
},
audio:
sourceAudio && text(sourceAudio.src)
? {
src: text(sourceAudio.src),
loop: sourceAudio.loop !== false,
}
: undefined,
typography: {
body: bodyFont || defaults.typography?.body || 'Sora Variable',
heading: headingFont || bodyFont || defaults.typography?.heading || 'Sora Variable',
},
fonts: normalizeFonts(raw.fonts),
elements: normalizeElements(raw.elements),
accentColor: color(raw.accentColor, '#ff006e'),
css: typeof raw.css === 'string' ? raw.css : '',
};
};
/** Resolve a path inside a theme package to an asset URL. */
export const themeAssetUrl = (base: string, path: string | undefined) => {
if (!path) return '';
if (/^(?:[a-z]+:)?\/\//i.test(path) || /^(?:data|blob|asset):/i.test(path)) return path;
const relative = path
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
return base ? `${base.replace(/\/+$/, '')}/${relative}` : relative;
};
/** A CSS background value used for theme cards in the manager. */
export const themeV2ThumbnailStyle = (definition: ThemeV2Definition, assets = '') => {
const background = definition.background;
switch (background.type) {
case 'color':
return `background-color:${background.color};`;
case 'image':
return background.src
? `background-image:url("${themeAssetUrl(assets, background.src)}");background-size:cover;background-position:center;`
: `background-color:#181825;`;
case 'video':
return `background:linear-gradient(135deg, ${definition.accentColor ?? '#ff006e'}55, #181825);`;
default:
return `background:linear-gradient(${background.angle ?? 135}deg, ${background.from}, ${background.to});`;
}
};
const channel = (value: string) => parseInt(value, 16) / 255;
const relativeLuminance = (hex: string) => {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
if (!match) return 0;
let value = match[1];
if (value.length === 3)
value = value
.split('')
.map((part) => part + part)
.join('');
const [r, g, b] = [value.slice(0, 2), value.slice(2, 4), value.slice(4, 6)].map(channel);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
export const readableTextColor = (hex: string) =>
relativeLuminance(hex) > 0.55 ? '#181825' : '#ffffff';
/** @font-face declarations for fonts bundled in a theme package. */
export const themeFontFaceCss = (
definition: ThemeV2Definition,
assets = '',
assetOverrides: Record<string, string> = {}
) =>
(definition.fonts ?? [])
.map(
(font) =>
`@font-face{font-family:${fontFamilyCss(font.family)};src:url("${
assetOverrides[font.src] ?? themeAssetUrl(assets, font.src)
}") format("${fontFormat(font.src)}");font-weight:${font.weight ?? '400'};font-style:${
font.style ?? 'normal'
};font-display:swap;}`
)
.join('\n');
/** CSS injected while a v2 theme is active. */
export const themeV2Css = (
definition: ThemeV2Definition,
assets = '',
assetOverrides: Record<string, string> = {}
) => {
const accent = definition.accentColor;
// `.dark` also defines the primary variables, so the override needs a
// selector that is at least as specific as `html.dark`.
const accentCss = accent
? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${readableTextColor(accent)};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${readableTextColor(accent)};}`
: '';
const body = definition.typography?.body?.trim();
const heading = definition.typography?.heading?.trim();
const fontFaces = themeFontFaceCss(definition, assets, assetOverrides);
const typographyCss = body
? `:root,:root.dark{--font-sans:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}body{font-family:${fontFamilyCss(body)},ui-sans-serif,system-ui,sans-serif;}`
: '';
const headingCss = heading
? `:root,:root.dark{--font-heading:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}h1,h2,h3,h4,h5,h6,.theme-heading{font-family:${fontFamilyCss(heading)},ui-sans-serif,system-ui,sans-serif;}`
: '';
return [accentCss, fontFaces, typographyCss, headingCss, definition.css?.trim()]
.filter(Boolean)
.join('\n');
};
/** A safe, predictable asset filename for the theme editor. */
export const themeAssetName = (sourcePath: string, prefix: string) => {
const base = sourcePath.split(/[\\/]/).pop() ?? 'asset';
const dot = base.lastIndexOf('.');
const extension =
dot > 0
? base
.slice(dot)
.toLowerCase()
.replace(/[^a-z0-9.]/g, '')
: '';
const stem =
(dot > 0 ? base.slice(0, dot) : base)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 48) || 'asset';
return `${prefix}_${stem}${extension}`;
};
+295 -168
View File
@@ -1,8 +1,9 @@
import { active_custom_theme, custom_themes } from './global';
import { active_custom_theme, custom_theme_audio_playing, custom_themes } from './global';
import { clearThemeMediaCache } from './themeMedia';
import { isThemeV2, normalizeThemeV2, type ThemeV2Asset, type ThemeV2Definition } from './themeV2';
import { disableMediaControls, setGlobalVolume } from './utils';
import { betterFetch } from '@better-fetch/fetch';
import { convertFileSrc } from '@tauri-apps/api/core';
import { invoke } from '@tauri-apps/api/core';
import { convertFileSrc, invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
@@ -12,7 +13,10 @@ export type Theme = {
folder_name: string;
name: string;
version: string;
apiVersion: string;
author: string;
description: string;
nsfw: boolean;
scriptUrl: string;
cssStyle: string;
assets: string;
@@ -20,16 +24,19 @@ export type Theme = {
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
progress: number;
updateAvailable: boolean;
definition?: ThemeV2Definition;
};
export type ThemeInfo = {
name: string;
version: string;
apiVersion: string;
author: string;
entry: string;
style: string;
preview: string;
version?: string;
apiVersion?: string;
author?: string;
description?: string;
nsfw?: boolean;
entry?: string;
style?: string;
preview?: string;
};
type DownloadProgress = {
@@ -47,6 +54,44 @@ type ExtractProgress = {
current_file: string;
};
const THEME_REPOSITORY = 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes';
let themeAssetBasePromise: Promise<string> | undefined;
/**
* Linux cannot load theme media from the custom asset protocol, so the native
* side exposes a loopback HTTP base for theme files. Everywhere else this is
* empty and the normal asset protocol is used.
*/
export const themeAssetBase = (): Promise<string> => {
themeAssetBasePromise ??= invoke<string>('theme_asset_base').catch(() => '');
return themeAssetBasePromise;
};
const encodeThemePath = (value: string) =>
value
.replace(/\\/g, '/')
.replace(/^\.?\//, '')
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
export const themesDirectory = async () =>
await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
/**
* Replace a theme in the store instead of mutating it. The theme list is
* rendered from a filtered derived list, and Svelte treats each item as
* immutable there, so in-place mutation would not update the cards (e.g. the
* download status and progress would stay at their initial values).
*/
const patchTheme = (name: string, patch: (theme: Theme) => Theme) => {
custom_themes.update((themes) =>
themes.map((theme) => (theme.name === name ? patch(theme) : theme))
);
};
export const getDownloadableThemes = async () => {
const themes: Theme[] = [];
@@ -55,8 +100,11 @@ export const getDownloadableThemes = async () => {
name: string;
version: string;
author: string;
apiVersion?: string;
description?: string;
nsfw?: boolean;
}[]
>('https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes.json', {
>(`${THEME_REPOSITORY}/raw/branch/main/themes.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -71,10 +119,13 @@ export const getDownloadableThemes = async () => {
folder_name: `${toSafeName(theme.name)}-${generateRandomString(16)}`,
name: theme.name,
version: theme.version,
apiVersion: theme.apiVersion ?? '1.0',
author: theme.author,
description: theme.description ?? '',
nsfw: theme.nsfw === true,
scriptUrl: '',
assets: '',
preview: `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(theme.name)}.png`,
preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(theme.name)}.png`,
status: 'not-installed',
progress: 0,
updateAvailable: false,
@@ -88,6 +139,83 @@ export const getDownloadableThemes = async () => {
return themes;
};
const themeFromFolder = async (
folderName: string,
folderPath: string
): Promise<Theme | undefined> => {
const themeConfigFilePath = await path.join(folderPath, 'theme.json');
if (!(await fs.exists(themeConfigFilePath))) return undefined;
try {
const themeConfig = await fs.readTextFile(themeConfigFilePath);
const parsed = JSON.parse(themeConfig) as ThemeInfo & Partial<ThemeV2Definition>;
if (!parsed.name) return undefined;
const apiVersion = parsed.apiVersion ?? '1.0';
const isV2 = isThemeV2(apiVersion);
const definition = isV2
? normalizeThemeV2(parsed as unknown as Partial<ThemeV2Definition>)
: undefined;
const httpBase = await themeAssetBase();
const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : '';
const entry = parsed.entry ?? '';
let scriptUrl = '';
if (!isV2 && entry) {
const scriptFile = await path.join(folderPath, entry);
if (!(await fs.exists(scriptFile))) return undefined;
scriptUrl = httpFolder
? `${httpFolder}/${encodeThemePath(entry)}`
: convertFileSrc(await path.normalize(scriptFile));
}
const style = parsed.style ?? '';
let cssStyle = '';
if (style) {
const cssStylePath = await path.join(folderPath, style);
if (await fs.exists(cssStylePath)) cssStyle = await fs.readTextFile(cssStylePath);
}
const assets = httpFolder
? `${httpFolder}/assets`
: convertFileSrc(await path.normalize(await path.join(folderPath, 'assets')));
const previewName = parsed.preview || (isV2 ? 'preview.png' : '');
let preview = '';
if (previewName) {
const previewPath = await path.join(folderPath, 'assets', previewName);
if (await fs.exists(previewPath)) {
// The editor rewrites preview.png on every save. A cache-busting query
// makes the WebView pick up the new file instead of its cached asset.
preview = httpFolder
? `${httpFolder}/assets/${encodeThemePath(previewName)}?v=${Date.now()}`
: `${convertFileSrc(await path.normalize(previewPath))}?v=${Date.now()}`;
}
}
return {
folder_name: folderName,
name: parsed.name,
version: parsed.version ?? '1.0.0',
apiVersion,
author: parsed.author ?? 'Unknown',
description: parsed.description ?? '',
nsfw: parsed.nsfw === true,
scriptUrl,
assets,
preview,
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle,
definition,
};
} catch (error) {
console.error(`Failed to read theme ${folderName}`, error);
return undefined;
}
};
export const getThemes = async (): Promise<Theme[]> => {
const themes: Theme[] = [];
themes.push({
@@ -95,6 +223,9 @@ export const getThemes = async (): Promise<Theme[]> => {
author: 'EZPPFarm',
name: 'Default',
version: '1.0.0',
apiVersion: '1.0',
description: 'The default EZPPLauncher look.',
nsfw: false,
scriptUrl: '',
assets: '',
preview: '',
@@ -103,36 +234,16 @@ export const getThemes = async (): Promise<Theme[]> => {
updateAvailable: false,
cssStyle: '',
});
const folderPath = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const folderPath = await themesDirectory();
if (await fs.exists(folderPath)) {
const themeFolders = await fs.readDir(folderPath);
for (const themeFolder of themeFolders) {
const themeFolderPath = await path.join(folderPath, themeFolder.name);
const themeConfigFilePath = await path.join(folderPath, themeFolder.name, 'theme.json');
if (await fs.exists(themeConfigFilePath)) {
const themeConfig = await fs.readTextFile(themeConfigFilePath);
const theme = JSON.parse(themeConfig) as ThemeInfo;
const scriptFile = await path.join(folderPath, themeFolder.name, theme.entry);
const cssStylePath = await path.join(folderPath, themeFolder.name, theme.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
if (await fs.exists(scriptFile)) {
themes.push({
folder_name: themeFolder.name,
name: theme.name,
version: theme.version,
author: theme.author,
scriptUrl: convertFileSrc(await path.normalize(`${themeFolderPath}/${theme.entry}`)),
assets: convertFileSrc(await path.normalize(`${themeFolderPath}/assets`)),
preview: convertFileSrc(
await path.normalize(`${themeFolderPath}/assets/${theme.preview}`)
),
status: 'installed',
progress: 0,
updateAvailable: false,
cssStyle,
});
}
}
const theme = await themeFromFolder(
themeFolder.name,
await path.join(folderPath, themeFolder.name)
);
if (theme) themes.push(theme);
}
}
return themes;
@@ -144,11 +255,7 @@ export const downloadTheme = async (
): Promise<{ success: boolean; error?: string }> => {
if (theme.status !== 'not-installed' && !force) return { success: false };
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'downloading';
return themes;
});
patchTheme(theme.name, (current) => ({ ...current, status: 'downloading' }));
const themeFileName = `${toSafeName(theme.name)}.ezpplauncher-theme`;
const themeContents = await betterFetch<{
@@ -158,55 +265,56 @@ export const downloadTheme = async (
);
if (themeContents.error) {
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
return {
success: false,
error: themeContents.error.message ?? 'An unknown error occurred',
};
}
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const themeFolder = await path.join(baseThemeFolder, toSafeName(theme.name));
const baseThemeFolder = await themesDirectory();
const folderName = toSafeName(theme.name);
const themeFolder = await path.join(baseThemeFolder, folderName);
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const downloadUrl = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/themes/${themeFileName}`;
const downloadUrl = `${THEME_REPOSITORY}/raw/branch/main/themes/${themeFileName}`;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'downloading';
matchedTheme.progress = 0;
}
return themes;
});
patchTheme(theme.name, (current) => ({
...current,
status: 'downloading',
progress: 0,
}));
let extractionStartedAt = 0;
const beginExtraction = (progress: number) => {
if (extractionStartedAt === 0) extractionStartedAt = Date.now();
patchTheme(theme.name, (current) => ({
...current,
status: 'extracting',
progress,
}));
};
const unlistenDownload = await listen<DownloadProgress>('download_progress', (event) => {
if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'downloading';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
if (event.payload.total > 0 && event.payload.received >= event.payload.total) {
beginExtraction(0);
return;
}
patchTheme(theme.name, (current) => ({
...current,
status: 'downloading',
progress: event.payload.progress,
}));
});
const unlistenExtract = await listen<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== theme.name) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.status = 'extracting';
matchedTheme.progress = event.payload.progress;
}
return themes;
});
beginExtraction(event.payload.progress);
});
try {
@@ -219,50 +327,55 @@ export const downloadTheme = async (
} catch (err) {
console.log(err);
const erro = err as Error;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) matchedTheme.status = 'not-installed';
return themes;
});
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
return { success: false, error: erro.message || 'Failed to download or extract theme.' };
} finally {
unlistenDownload();
unlistenExtract();
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
// Small themes can extract in a few milliseconds, before the webview paints
// the extracting state. Keep it visible long enough to be readable, while
// still letting large themes finish naturally.
if (extractionStartedAt === 0) {
beginExtraction(0);
}
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === theme.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
matchedTheme.folder_name = toSafeName(theme.name);
}
return themes;
});
const minimumExtractionMs = 700;
const elapsed = Date.now() - extractionStartedAt;
if (elapsed < minimumExtractionMs) {
await new Promise((resolve) => setTimeout(resolve, minimumExtractionMs - elapsed));
}
const installedTheme = await themeFromFolder(folderName, themeFolder);
if (!installedTheme) {
patchTheme(theme.name, (current) => ({ ...current, status: 'not-installed' }));
return { success: false, error: 'The downloaded theme package is invalid.' };
}
patchTheme(theme.name, (current) => ({
...current,
...installedTheme,
status: 'installed',
progress: 1,
}));
await reloadThemes();
return { success: true };
};
export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => {
// Theme media is buffered as blob URLs; the previous theme's URLs are no
// longer needed once it is replaced.
clearThemeMediaCache();
active_custom_theme.set(theme);
custom_theme_audio_playing.set(false);
themeContainer.innerHTML = '';
if (theme.definition) {
setGlobalVolume(volume);
disableMediaControls();
return;
}
if (theme.scriptUrl.length > 0) {
const themeScript = await import(/* @vite-ignore */ theme.scriptUrl);
themeScript.mountTheme(themeContainer, { assets: theme.assets });
@@ -275,7 +388,7 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
if (themeToUninstall.status !== 'installed') {
return false;
}
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const baseThemeFolder = await themesDirectory();
if (!(await fs.exists(baseThemeFolder))) {
return false;
}
@@ -283,27 +396,19 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
if (!(await fs.exists(themeFolder))) {
return false;
}
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.status = 'deleting';
}
return themes;
});
patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'deleting' }));
await fs.remove(themeFolder, { recursive: true });
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeToUninstall.name);
if (matchedTheme) {
matchedTheme.scriptUrl = '';
matchedTheme.cssStyle = '';
matchedTheme.assets = '';
matchedTheme.preview = `https://git.ez-pp.farm/EZPPFarm/EZPPLauncher-Themes/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`;
matchedTheme.status = 'not-installed';
matchedTheme.updateAvailable = false;
}
return themes;
});
patchTheme(themeToUninstall.name, (current) => ({
...current,
scriptUrl: '',
cssStyle: '',
assets: '',
definition: undefined,
preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`,
status: 'not-installed',
updateAvailable: false,
}));
await reloadThemes();
return true;
@@ -327,18 +432,14 @@ export const checkThemeFromFile = async (filePath: string) => {
};
export const importThemeFromFile = async (themeName: string, filePath: string) => {
const baseThemeFolder = await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
const baseThemeFolder = await themesDirectory();
const themeFolder = await path.join(baseThemeFolder, toSafeName(themeName));
if (!(await fs.exists(baseThemeFolder))) await fs.mkdir(baseThemeFolder, { recursive: true });
if (!(await fs.exists(themeFolder))) await fs.mkdir(themeFolder, { recursive: true });
const unlisten = await listen<ExtractProgress>('extract_progress', (event) => {
if (event.payload.theme_name !== themeName) return;
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeName);
if (matchedTheme) matchedTheme.progress = event.payload.progress;
return themes;
});
patchTheme(themeName, (current) => ({ ...current, progress: event.payload.progress }));
});
try {
@@ -354,30 +455,9 @@ export const importThemeFromFile = async (themeName: string, filePath: string) =
unlisten();
}
const themeInfo = await fs.readTextFile(await path.join(themeFolder, 'theme.json'));
const themeInfoObj = JSON.parse(themeInfo) as ThemeInfo;
const themeScriptUrl = convertFileSrc(
await path.normalize(`${themeFolder}/${themeInfoObj.entry}`)
);
const themeAssets = convertFileSrc(await path.normalize(`${themeFolder}/assets`));
const themePreview = convertFileSrc(
await path.normalize(`${themeFolder}/assets/${themeInfoObj.preview}`)
);
const cssStylePath = await path.join(themeFolder, themeInfoObj.style);
const cssStyle = (await fs.exists(cssStylePath)) ? await fs.readTextFile(cssStylePath) : '';
custom_themes.update((themes) => {
const matchedTheme = themes.find((t) => t.name === themeInfoObj.name);
if (matchedTheme) {
matchedTheme.scriptUrl = themeScriptUrl;
matchedTheme.cssStyle = cssStyle;
matchedTheme.assets = themeAssets;
matchedTheme.preview = themePreview;
matchedTheme.status = 'installed';
matchedTheme.progress = 1;
}
return themes;
});
if (!(await themeFromFolder(toSafeName(themeName), themeFolder))) {
return { success: false, error: 'The theme package is missing its theme.json or entry file.' };
}
await reloadThemes();
return { success: true };
@@ -396,8 +476,12 @@ export const reloadThemes = async () => {
const installed = installedMap.get(downloadable.name);
if (installed) {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
try {
if (new SemVer(downloadable.version).compare(new SemVer(installed.version)) > 0) {
combinedMap.get(downloadable.name)!.updateAvailable = true;
}
} catch {
// Ignore malformed versions instead of breaking the whole theme list.
}
} else {
combinedMap.set(downloadable.name, downloadable);
@@ -410,21 +494,31 @@ export const reloadThemes = async () => {
const storeMap = new Map(storeThemes.map((t) => [t.name, t]));
const ACTIVE_STATUSES = new Set(['downloading', 'extracting', 'deleting']);
for (const theme of combinedThemes) {
const storeTheme = storeMap.get(theme.name);
if (!storeTheme || ACTIVE_STATUSES.has(storeTheme.status)) continue;
const updatedThemes = storeThemes.map((storeTheme) => {
if (ACTIVE_STATUSES.has(storeTheme.status)) return storeTheme;
const theme = combinedMap.get(storeTheme.name);
if (!theme) return storeTheme;
storeTheme.scriptUrl = theme.scriptUrl;
storeTheme.assets = theme.assets;
storeTheme.preview = theme.preview;
storeTheme.version = theme.version;
storeTheme.author = theme.author;
storeTheme.updateAvailable = theme.updateAvailable;
storeTheme.cssStyle = theme.cssStyle;
}
// Replace the object rather than mutating it so the theme cards see the
// new values when the list is rendered from a filtered derived array.
return {
...storeTheme,
scriptUrl: theme.scriptUrl,
assets: theme.assets,
preview: theme.preview,
version: theme.version,
author: theme.author,
apiVersion: theme.apiVersion,
description: theme.description,
nsfw: theme.nsfw,
definition: theme.definition,
updateAvailable: theme.updateAvailable,
cssStyle: theme.cssStyle,
};
});
const newThemes = combinedThemes.filter((t) => !storeMap.has(t.name));
const merged = [...storeThemes, ...newThemes];
const merged = [...updatedThemes, ...newThemes];
return merged.sort((a, b) => {
if (a.name === 'Default') return -1;
@@ -434,6 +528,39 @@ export const reloadThemes = async () => {
return a.name.localeCompare(b.name);
});
});
active_custom_theme.update((active) => {
if (!active) return active;
const updated = combinedMap.get(active.name);
return updated ? { ...active, ...updated } : active;
});
};
export const saveThemeProject = async (
folderName: string,
definition: ThemeV2Definition,
assets: ThemeV2Asset[] = []
) => {
const themeFolder = await path.join(await themesDirectory(), folderName);
await invoke('save_theme_project', {
themeFolder,
config: JSON.stringify(definition, null, 2),
assets,
});
return themeFolder;
};
export const exportThemeFolder = async (folderName: string, destinationPath: string) => {
const themeFolder = await path.join(await themesDirectory(), folderName);
await invoke('export_theme', { themeFolder, destinationPath });
};
export const exportTheme = async (theme: Theme, destinationPath: string) =>
exportThemeFolder(theme.folder_name, destinationPath);
export const openThemesFolder = async () => {
const folder = await themesDirectory();
await invoke('open_theme_folder', { themeFolder: folder });
};
export const toSafeName = (name: string) => {
+1
View File
@@ -5,6 +5,7 @@ export const cursorSmoothening = writable<boolean>(false);
export const cursorSmoothness = writable<number>(180);
export const reduceAnimations = writable<boolean>(false);
export const patch = writable<boolean>(true);
export const loggingEnabled = writable<boolean>(true);
export const osuInstallationPath = writable<string>('');
+3 -1
View File
@@ -163,7 +163,9 @@ export function trackMediaContainer(node: HTMLElement) {
const media = new Set<HTMLMediaElement>();
function update() {
custom_theme_audio_playing.set(Array.from(media).some((el) => !el.paused));
custom_theme_audio_playing.set(
Array.from(media).some((el) => !el.paused && !(el instanceof HTMLVideoElement && el.muted))
);
}
function register(el: HTMLMediaElement) {
+103 -48
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import Logo from '$assets/logo.png';
import ThemeEditorWindow from '$lib/components/theme/ThemeEditorWindow.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import AnimatedBg from '$lib/components/ui/animated-bg/AnimatedBg.svelte';
import Button from '$lib/components/ui/button/button.svelte';
@@ -24,12 +27,22 @@
setupValues,
trackingEnabled,
} from '$lib/global';
import { exit, getLauncherVersion, getPlatform } from '$lib/osuUtil';
import { initLogging } from '$lib/logging';
import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil';
import * as presence from '$lib/presence';
import { smoothThemeMedia } from '$lib/themeMedia';
import { themeV2Css } from '$lib/themeV2';
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
import { cursorSmoothening, customCursor, patch, reduceAnimations } from '$lib/userSettings';
import {
cursorSmoothening,
customCursor,
loggingEnabled,
patch,
reduceAnimations,
} from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils';
import '../app.css';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Buffer } from 'buffer';
import { SemVer } from 'semver';
import { sileo } from 'sileo';
@@ -38,6 +51,17 @@
let { children } = $props();
if (browser) initLogging();
const editorWindow = (() => {
if (!browser) return false;
try {
return getCurrentWindow().label === 'theme-editor';
} catch {
return false;
}
})();
let unsupported_platform = $state<boolean>(false);
function disableReload() {
@@ -83,12 +107,18 @@
}
onMount(async () => {
if (editorWindow) return;
window.Buffer = Buffer;
disableReload();
setupValues();
launcherVersion.set(await getLauncherVersion());
const currentPlatform = await getPlatform();
platform.set(currentPlatform);
if (!['windows', 'linux', 'macos'].includes(currentPlatform)) unsupported_platform = true;
const oldUserConfig = new Config('user_settings', false);
const oldUserConfigExists = await oldUserConfig.init();
if (oldUserConfigExists) {
@@ -127,6 +157,7 @@
const config_custom_cursor = config.value<boolean>('custom_cursor');
const config_cursor_smoothening = config.value<boolean>('cursor_smoothening');
const config_reduce_animations = config.value<boolean>('reduce_animations');
const config_logging_enabled = config.value<boolean>('logging_enabled');
const config_discord_presence = config.value<boolean>('discord_presence');
const config_tracking_enabled = config.value<boolean>('tracking_consent');
const config_otd_enabled = config.value<boolean>('otd_enabled');
@@ -135,7 +166,7 @@
const localThemes = await getThemes();
const lastThemeName = await config_theme.get('Default');
const last_theme = localThemes.find((t) => t.name === lastThemeName);
if (!last_theme || $platform === 'linux') {
if (!last_theme) {
await config.value<string>('theme').set('Default');
loadTheme(localThemes[0], $custom_theme_container!, await config_theme_volume.get(0.15));
} else {
@@ -147,6 +178,7 @@
customCursor.set(await config_custom_cursor.get(true));
cursorSmoothening.set(await config_cursor_smoothening.get(true));
reduceAnimations.set(await config_reduce_animations.get(false));
loggingEnabled.set(await config_logging_enabled.get(true));
discordPresence.set(await config_discord_presence.get(true));
openTabletDriverEnabled.set(await config_otd_enabled.get(false));
openTabletDriverPath.set(await config_otd_path.get(''));
@@ -158,6 +190,10 @@
customCursor.subscribe(async (val) => await config_custom_cursor.set(val));
cursorSmoothening.subscribe(async (val) => await config_cursor_smoothening.set(val));
reduceAnimations.subscribe(async (val) => await config_reduce_animations.set(val));
loggingEnabled.subscribe(async (val) => {
await config_logging_enabled.set(val);
await setLoggingEnabled(val);
});
openTabletDriverEnabled.subscribe(async (val) => config_otd_enabled.set(val));
discordPresence.subscribe(async (val) => {
@@ -221,21 +257,21 @@
description: 'Failed to load themes',
});
}
platform.set(await getPlatform());
if ($platform !== 'windows' && $platform !== 'linux') unsupported_platform = true;
});
</script>
<svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${$active_custom_theme?.cssStyle || ''}</style>`}
{#if $active_custom_theme?.definition}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${themeV2Css(
$active_custom_theme.definition,
$active_custom_theme.assets
)}</style>`}
{/if}
</svelte:head>
{#if $customCursor}
<OsuCursor smoothCursor={$cursorSmoothening} />
{/if}
<SileoToast
position="top-center"
options={{
@@ -246,45 +282,64 @@
}}
/>
<Titlebar />
{#if editorWindow}
<ThemeEditorWindow />
{:else}
{#if $customCursor}
<OsuCursor smoothCursor={$cursorSmoothening} />
{/if}
<AlertDialog.Root open={unsupported_platform}>
<AlertDialog.Content class="bg-theme-950 border-theme-800 p-0">
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<img class="h-20 w-20" src={Logo} alt="logo" />
<span class="font-semibold text-xl">Unsupported Platform!</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
This Platform is not supported by EZPPLauncher.
</div>
<div class="flex items-center justify-center mb-3">
<Button
onclick={async () => {
await exit();
}}>Close</Button
>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<Titlebar />
<main>
<div class="opacity-30">
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
<AlertDialog.Root open={unsupported_platform}>
<AlertDialog.Content class="bg-theme-950 border-theme-800 p-0">
<div
class={$active_custom_theme && $active_custom_theme.name === 'Default'
? 'hidden'
: 'absolute z-0 top-0 left-0 w-full h-full object-cover object-center aspect-video'}
bind:this={$custom_theme_container}
use:trackMediaContainer
></div>
{#if ($active_custom_theme && $active_custom_theme.name === 'Default') || !$active_custom_theme}
<AnimatedBg />
{/if}
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<img class="h-20 w-20" src={Logo} alt="logo" />
<span class="font-semibold text-xl">Unsupported Platform!</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
This Platform is not supported by EZPPLauncher.
</div>
<div class="flex items-center justify-center mb-3">
<Button
onclick={async () => {
await exit();
}}>Close</Button
>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<main>
<div class:hidden={!!$active_custom_theme?.definition}>
<div class="opacity-30">
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
<div
class={$active_custom_theme && $active_custom_theme.name === 'Default'
? 'hidden'
: 'absolute z-0 top-0 left-0 w-full h-full object-cover object-center aspect-video'}
bind:this={$custom_theme_container}
use:trackMediaContainer
use:smoothThemeMedia
></div>
{#if ($active_custom_theme && $active_custom_theme.name === 'Default') || !$active_custom_theme}
<AnimatedBg />
{/if}
</div>
</div>
</div>
</div>
{@render children()}
</main>
{#if $active_custom_theme?.definition}
<div class="absolute inset-0 z-0">
<ThemeV2Background
definition={$active_custom_theme.definition}
assets={$active_custom_theme.assets}
/>
</div>
{/if}
{@render children()}
</main>
{/if}
+673 -281
View File
File diff suppressed because it is too large. Load diff
+622 -299
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { dev } from '$app/environment';
import Logo from '$assets/logo.png';
import ConfettiCannon from '$lib/components/confetti/ConfettiCannon.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/config';
import {
@@ -13,6 +13,7 @@
discordPresence,
osuBuild,
osuStream,
platform,
presenceLoading,
skinsCount,
} from '$lib/global';
@@ -23,7 +24,8 @@
getSkin,
getSkinsCount,
getVersion,
isValidOsuFolder,
pickOsuInstallation,
resolveOsuFolder,
} from '$lib/osuUtil';
import {
cursorSmoothening,
@@ -33,345 +35,666 @@
reduceAnimations,
} from '$lib/userSettings';
import Launch from './Launch.svelte';
import { Check, CircleCheckBig, CircleOff, LoaderCircle } from '@lucide/svelte';
import { open } from '@tauri-apps/plugin-dialog';
import { animate } from 'animejs';
import {
ArrowLeft,
ArrowRight,
Check,
CircleAlert,
CircleCheckBig,
FolderOpen,
Info,
LoaderCircle,
Palette,
RefreshCw,
Rocket,
Search,
} from '@lucide/svelte';
import { onMount } from 'svelte';
import Confetti from 'svelte-confetti';
import { fade } from 'svelte/transition';
import { fly } from 'svelte/transition';
let selectedStep = $state(1);
const steps = ['Welcome', 'Locate your osu! Installation', 'Appearance Settings'];
const steps = ['Welcome', 'osu! installation', 'Personalize'];
type DetectionStatus = 'detecting' | 'valid' | 'invalid' | 'missing';
let stepIndex = $state(0);
let osuInstallPath = $state('');
let manualSelect = $state(false);
let manualSelectValid = $state(false);
let autoDetectedOsuPath = $state(false);
let wizardFinished = $state(false);
let manualSelection = $state(false);
let selectionError = $state(false);
let detecting = $state(true);
let detectionFinished = $state(false);
let selecting = $state(false);
let saving = $state(false);
let saveError = $state<string | undefined>(undefined);
let finished = $state(false);
let detectionRun = 0;
let ezppLogo: HTMLImageElement | undefined = $state(undefined);
const stepNumber = $derived(stepIndex + 1);
const logo_mouseenter = () => {
if (ezppLogo) {
animate(ezppLogo, {
duration: 700,
scale: 1.2,
ease: (t: number) => Math.pow(2, -5 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
});
const status: DetectionStatus = $derived(
detecting
? 'detecting'
: selectionError
? 'invalid'
: osuInstallPath
? 'valid'
: detectionFinished
? 'missing'
: 'detecting'
);
const statusTitle = $derived(
status === 'detecting'
? 'Looking for osu!'
: status === 'valid'
? manualSelection
? 'osu! installation selected'
: 'osu! found'
: status === 'invalid'
? 'No osu! in that folder'
: 'osu! not found yet'
);
const statusDescription = $derived(
status === 'detecting'
? 'Checking your applications and Wine bottles…'
: status === 'valid'
? manualSelection
? 'That folder contains a valid osu! installation.'
: 'This installation was found automatically.'
: status === 'invalid'
? $platform === 'macos'
? 'Pick the osu!.app wrapper, or the folder that contains osu!.exe.'
: 'Pick the folder that contains osu!.exe.'
: 'Browse to your installation to continue.'
);
const statusBorderClass = $derived(
status === 'valid'
? 'border-emerald-500/25 bg-emerald-500/5'
: status === 'invalid'
? 'border-red-500/25 bg-red-500/5'
: 'border-theme-800/70 bg-black/15'
);
const statusIconClass = $derived(
status === 'valid'
? 'bg-emerald-500/10 text-emerald-400'
: status === 'invalid'
? 'bg-red-500/10 text-red-400'
: status === 'missing'
? 'bg-amber-500/10 text-amber-400'
: 'bg-primary/10 text-primary'
);
const canContinue = $derived(stepIndex !== 1 || osuInstallPath.length > 0 || dev);
const detectOsu = async () => {
const run = ++detectionRun;
detecting = true;
selectionError = false;
try {
const detectedPath = await autoDetectOsuInstallFolder();
if (run !== detectionRun) return;
if (detectedPath) {
osuInstallPath = detectedPath;
manualSelection = false;
}
} catch (error) {
console.warn('Could not auto-detect the osu! installation.', error);
} finally {
if (run === detectionRun) {
detecting = false;
detectionFinished = true;
}
}
};
const logo_mouseleave = () => {
if (ezppLogo) {
animate(ezppLogo, {
duration: 700,
scale: 1,
ease: (t: number) => (t - 1) ** 7 + 1,
});
}
};
const browseOsu = async () => {
if (selecting) return;
const browse_osu_installation = async () => {
const selectedPath = await open({
directory: true,
multiple: false,
title: 'Select osu! Installation Folder',
});
detectionRun += 1;
detecting = false;
detectionFinished = true;
selectionError = false;
selecting = true;
if (typeof selectedPath === 'string') {
const validFolder = await isValidOsuFolder(selectedPath);
manualSelect = true;
if (!validFolder) {
manualSelectValid = false;
try {
const selectedPath = await pickOsuInstallation($platform);
if (typeof selectedPath !== 'string') return;
const resolvedPath = await resolveOsuFolder(selectedPath);
if (!resolvedPath) {
osuInstallPath = '';
manualSelection = false;
selectionError = true;
return;
}
osuInstallPath = selectedPath;
autoDetectedOsuPath = false;
manualSelectValid = true;
osuInstallPath = resolvedPath;
manualSelection = true;
} catch (error) {
console.error('Could not select the osu! installation.', error);
osuInstallPath = '';
manualSelection = false;
selectionError = true;
} finally {
selecting = false;
}
};
const nextStep = () => {
if (stepIndex < steps.length - 1) {
stepIndex += 1;
return;
}
finished = true;
};
const previousStep = () => {
if (finished) {
finished = false;
return;
}
stepIndex = Math.max(stepIndex - 1, 0);
};
const saveConfig = async () => {
await config.value<string>('osu_installation_path').set(osuInstallPath);
osuInstallationPath.set(osuInstallPath);
await config.value<boolean>('setup_complete').set(true);
if (saving) return;
saving = true;
saveError = undefined;
const beatmapSetCount = await getBeatmapSetsCount(osuInstallPath);
if (beatmapSetCount) {
beatmapSets.set(beatmapSetCount);
try {
await config.value<string>('osu_installation_path').set(osuInstallPath);
osuInstallationPath.set(osuInstallPath);
await config.value<boolean>('setup_complete').set(true);
if (osuInstallPath.length > 0) {
try {
const [beatmapSetCount, skinCount, skin, stream, version] = await Promise.all([
getBeatmapSetsCount(osuInstallPath),
getSkinsCount(osuInstallPath),
getSkin(osuInstallPath),
getReleaseStream(osuInstallPath),
getVersion(osuInstallPath),
]);
beatmapSets.set(beatmapSetCount ?? 0);
skinsCount.set(skinCount ?? 0);
currentSkin.set(skin ?? '');
osuStream.set(stream);
osuBuild.set(version);
} catch (error) {
console.warn('Could not load the osu! installation details.', error);
}
}
currentView.set(Launch);
} catch (error) {
console.error('Could not finish the setup.', error);
saveError = error instanceof Error ? error.message : String(error);
saving = false;
}
const skins_count = await getSkinsCount(osuInstallPath);
if (skins_count) {
/* skins.set(skins_count); */
skinsCount.set(skins_count);
} else {
skinsCount.set(0);
}
const skin: string = await getSkin(osuInstallPath);
currentSkin.set(skin);
const osuReleaseStream = await getReleaseStream(osuInstallPath);
osuStream.set(osuReleaseStream);
const osuVersion = await getVersion(osuInstallPath);
osuBuild.set(osuVersion);
currentView.set(Launch);
};
onMount(async () => {
const osuPath = await autoDetectOsuInstallFolder();
if (osuPath) {
osuInstallPath = osuPath;
autoDetectedOsuPath = true;
await config.value<string>('osu_installation_path').set(osuInstallPath);
}
onMount(() => {
void detectOsu();
});
</script>
{#if wizardFinished}
<div class="relative flex flex-col items-center justify-center h-screen z-50">
<div class="absolute h-fit w-fit top-1/2 left-1/2">
<Confetti amount={200} y={[-1, 1.5]} x={[-2.3, 2.3]} colorArray={['#C6A0F3']} />
{#if finished}
<div
class="relative flex h-screen w-full flex-col items-center justify-center overflow-hidden px-6"
>
<div
class="pointer-events-none absolute -top-32 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-primary/15 blur-[110px]"
></div>
{#if !$reduceAnimations}
<ConfettiCannon count={820} colors={['#ff006e', '#a78bfa', '#7dd3fc', '#fbbf24']} />
{/if}
<div
class="relative z-10 w-full max-w-md overflow-hidden rounded-2xl border border-theme-800/80 bg-theme-950/75 shadow-2xl shadow-black/40 backdrop-blur-xl"
>
<div class="flex flex-col items-center px-7 py-8 text-center">
<div
class="rounded-3xl bg-primary/20 p-3 ring-1 ring-inset ring-primary/25 shadow-xl shadow-primary/10"
>
<img src={Logo} alt="EZPPLauncher logo" class="size-20 object-contain" />
</div>
<h1 class="mt-5 text-2xl font-bold tracking-tight">You're all set!</h1>
<p class="mt-1.5 text-sm text-muted-foreground">
Everything is configured. Time to farm some pp.
</p>
{#if osuInstallPath}
<div
class="mt-5 w-full rounded-xl border border-theme-800/70 bg-black/20 px-3 py-2.5 text-left"
>
<div
class="flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase"
>
<FolderOpen class="size-3" />
osu! installation
</div>
<p class="mt-1 truncate font-mono text-[11px] text-theme-200" title={osuInstallPath}>
{osuInstallPath}
</p>
</div>
{/if}
{#if saveError}
<div
class="mt-4 flex w-full items-start gap-2 rounded-lg border border-red-500/20 bg-red-500/5 p-2.5 text-left text-xs text-red-300"
>
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<span>{saveError}</span>
</div>
{/if}
</div>
<div
class="flex items-center justify-between gap-3 border-t border-theme-800/70 bg-black/20 px-5 py-3.5"
>
<Button
variant="ghost"
class="text-muted-foreground hover:text-foreground"
onclick={previousStep}
disabled={saving}
>
<ArrowLeft />
Back
</Button>
<Button onclick={saveConfig} disabled={saving || (osuInstallPath.length === 0 && !dev)}>
{#if saving}
<LoaderCircle class="animate-spin" />
Saving…
{:else}
<Rocket />
Start farming
{/if}
</Button>
</div>
</div>
<h1 class="text-3xl font-semibold">EZPPLauncher Setup completed!</h1>
<p class="text-muted-foreground mt-2">You are now ready to farm some maps!</p>
<Button class="mt-4" onclick={saveConfig}>Finish</Button>
<p class="relative z-10 mt-4 text-[11px] text-muted-foreground">See you on the leaderboards.</p>
</div>
{:else}
<div class="grid grid-cols-[0.41fr_1fr] h-screen z-50">
<div class="relative h-screen w-full overflow-x-hidden overflow-y-auto">
<div
class="w-full h-full border-r border-black/40 bg-black/40 backdrop-blur-md flex flex-col gap-6 p-3 z-50"
class="pointer-events-none absolute -top-32 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-primary/15 blur-[110px]"
></div>
<div
class="relative z-10 mx-auto flex min-h-full w-full max-w-2xl flex-col justify-center px-6 pt-10 pb-3"
>
{#each steps as step, i (step)}
<div
class="flex flex-row items-center gap-2 border {selectedStep === i + 1
? 'border-primary-600/30 bg-primary-700/30'
: selectedStep > i
? 'border-green-800/30 bg-green-900/30'
: 'border-black/30 bg-black/30'} rounded-lg p-2 transition-all"
>
<header class="mb-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div
class="flex flex-col items-center justify-center h-8 w-8 border-2 {selectedStep > i + 1
? 'border-green-600'
: 'border-theme-600'} rounded-full"
class="rounded-2xl bg-primary/20 p-1 ring-1 ring-inset ring-primary/25 shadow-lg shadow-primary/10"
>
{#if selectedStep > i + 1}
<Check class="mt-0.5 text-green-400" />
{:else}
<span class="text-lg font-semibold text-theme-100">{i + 1}</span>
{/if}
<img src={Logo} alt="EZPPLauncher logo" class="size-10 object-contain" />
</div>
<div>
<p class="text-[10px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">
EZPPLauncher setup
</p>
<p class="text-sm font-semibold tracking-tight">Welcome aboard</p>
</div>
<span
class="{selectedStep === i + 1
? 'text-white'
: selectedStep > i
? 'text-green-500'
: "'text-muted-foreground'"} transition-all text-sm font-bold">{step}</span
>
</div>
{/each}
</div>
<div class="flex flex-col gap-6 w-full h-full backdrop-blur-md p-6 z-50">
{#if selectedStep === 1}
<div
class="my-auto h-full w-full bg-black/40 rounded-lg border border-black/40 p-6 mt-5 flex flex-col items-center justify-center"
in:fade={{ duration: $reduceAnimations ? 0 : 200 }}
<span
class="rounded-full border border-theme-800/80 bg-theme-950/60 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
>
<img
src={Logo}
alt="EZPPLauncher Logo"
class="w-52 h-52 mb-2"
bind:this={ezppLogo}
onmouseenter={logo_mouseenter}
onmouseleave={logo_mouseleave}
/>
<h1 class="text-3xl font-semibold">Welcome to EZPPLauncher!</h1>
<p class="text-muted-foreground mt-2">
This setup wizard will guide you through the initial setup of EZPPLauncher.
</p>
<div class="bg-red-800 border border-red-900 text-red-200 p-4 rounded-lg mt-4">
Please make sure you have osu! installed on your system before proceeding.
</div>
</div>
{:else if selectedStep === 2}
<div
class="my-auto h-full w-full bg-black/40 rounded-lg border border-black/40 p-6 mt-5 flex flex-col items-center justify-center"
in:fade={{ duration: $reduceAnimations ? 0 : 200 }}
>
<h1 class="text-3xl font-semibold">Locate your osu! Installation</h1>
<p class="text-muted-foreground mt-2">
Please select the folder where your osu! installation is located.
</p>
<div class="flex flex-row w-full">
<Input
class="mt-4 w-full bg-theme-950 border-theme-800 border-r-0 rounded-r-none"
type="text"
placeholder="Path to osu! installation"
value={osuInstallPath}
/>
<Button
class="mt-4 bg-theme-950 border-theme-800 rounded-l-none"
variant="outline"
onclick={browse_osu_installation}>Browse</Button
>
</div>
{#if !manualSelect}
{#if autoDetectedOsuPath}
<div
class="flex flex-row gap-3 bg-green-800 border border-green-900 text-green-500 p-4 rounded-lg mt-4"
>
<CircleCheckBig />
<span>Auto-detected osu! installation path! Please check if its correct!</span>
</div>
{:else}
<div
class="flex flex-row gap-3 bg-red-800 border border-red-900 text-red-500 p-4 rounded-lg mt-4"
>
<CircleOff />
<span>Could not auto-detect osu! installation path. Please select it manually.</span
>
</div>
{/if}
{:else if manualSelectValid}
<div
class="flex flex-row gap-3 bg-green-800 border border-green-900 text-green-500 p-4 rounded-lg mt-4"
>
<CircleCheckBig />
<span>Selected osu! installation path is valid!</span>
</div>
{:else}
<div
class="flex flex-row gap-3 bg-red-800 border border-red-900 text-red-500 p-4 rounded-lg mt-4"
>
<CircleOff />
<span
>Selected osu! installation path is invalid! Please select a valid osu!
installation.</span
>
</div>
{/if}
</div>
{:else if selectedStep === 3}
<div class="h-full w-full flex items-center justify-center">
<div
class="bg-black/40 backdrop-blur-sm py-8 px-8 rounded-lg"
in:fade={{ duration: $reduceAnimations ? 0 : 200 }}
Step {stepNumber} of {steps.length}
</span>
</header>
<ol class="mb-3 grid grid-cols-3 gap-1.5" aria-label="Setup progress">
{#each steps as step, index (step)}
<li
class="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors {index ===
stepIndex
? 'border-primary/30 bg-primary/10'
: index < stepIndex
? 'border-theme-800/70 bg-theme-900/50'
: 'border-theme-800/40 bg-black/10'}"
aria-current={index === stepIndex ? 'step' : undefined}
>
<div class="grid grid-cols-[1fr_auto] gap-y-5 items-center px-6">
<div class="flex flex-col">
<Label class="text-sm" for="setting-patch">Patching</Label>
<div class="text-muted-foreground text-xs">
Shows misses in Relax and Autopilot <!-- {#if $platform !== 'windows'}<span
class="text-red-500 bg-red-800/20 border border-red-600/20 p-0.5 mx-1 px-2 rounded-lg text-[0.55rem]!"
>currently only on windows!</span
>
{/if} -->
</div>
</div>
<Checkbox
id="setting-patch"
checked={$patch}
onCheckedChange={async (e) => {
patch.set(e);
}}
class="flex items-center justify-center w-5 h-5"
></Checkbox>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold {index <
stepIndex
? 'bg-primary text-primary-foreground'
: index === stepIndex
? 'bg-primary/20 text-primary ring-1 ring-primary/30 ring-inset'
: 'bg-theme-800 text-muted-foreground'}"
>
{#if index < stepIndex}
<Check class="size-3" />
{:else}
{index + 1}
{/if}
</span>
<span
class="truncate text-[11px] font-semibold {index === stepIndex
? 'text-foreground'
: index < stepIndex
? 'text-theme-200'
: 'text-muted-foreground'}">{step}</span
>
</li>
{/each}
</ol>
<div class="flex flex-col">
<Label class="text-sm" for="setting-custom-cursor">Lazer-Style Cursor</Label>
<div class="text-muted-foreground text-xs">
Enable a custom cursor in the Launcher like in the lazer build of osu!
</div>
</div>
<Checkbox
id="setting-custom-cursor"
checked={$customCursor}
onCheckedChange={async (e) => {
if (!e) {
cursorSmoothening.set(false);
}
customCursor.set(e);
}}
class="flex items-center justify-center w-5 h-5"
></Checkbox>
<div class="flex flex-col">
<Label class="text-sm" for="setting-cursor-smoothening">Cursor Smoothening</Label>
<div class="text-muted-foreground text-xs">
Makes the custom cursor movement smoother.
</div>
</div>
<Checkbox
id="setting-cursor-smoothening"
checked={$cursorSmoothening}
onCheckedChange={async (e) => {
if (!$customCursor) return;
cursorSmoothening.set(e);
}}
disabled={!$customCursor}
class="flex items-center justify-center w-5 h-5"
></Checkbox>
<div class="flex flex-col">
<Label class="text-sm" for="setting-reduce-animations">Reduce Animations</Label>
<div class="text-muted-foreground text-xs">
Disables some animations in the Launcher to improve performance on low-end
devices.
</div>
</div>
<Checkbox
id="setting-reduce-animations"
checked={$reduceAnimations}
onCheckedChange={async (e) => {
reduceAnimations.set(e);
}}
class="flex items-center justify-center w-5 h-5"
></Checkbox>
<div class="flex flex-col">
<Label class="text-sm" for="setting-rich-presence">Discord Rich Presence</Label>
<div class="text-muted-foreground text-xs">
Let other discord users show what you are doing right now 👀
</div>
</div>
<div class="relative">
{#if $presenceLoading}
<div class="-left-8 absolute" transition:fade>
<LoaderCircle class="animate-spin" />
<div
class="overflow-hidden rounded-2xl border border-theme-800/80 bg-theme-950/75 shadow-2xl shadow-black/40 backdrop-blur-xl"
>
<div class="p-5">
{#key stepIndex}
<div in:fly={{ y: 6, duration: $reduceAnimations ? 0 : 200, opacity: 0 }}>
{#if stepIndex === 0}
<div class="flex min-h-[332px] flex-col">
<div>
<p class="text-[10px] font-semibold tracking-[0.2em] text-primary uppercase">
Welcome
</p>
<h2 class="mt-1.5 text-2xl font-bold tracking-tight">Let's get you farming.</h2>
<p class="mt-1.5 max-w-lg text-sm leading-relaxed text-muted-foreground">
EZPPLauncher keeps osu! updated, patched and looking the way you want. This
takes less than a minute.
</p>
</div>
{/if}
<Checkbox
id="setting-rich-presence"
bind:checked={$discordPresence}
disabled={$presenceLoading}
class="flex items-center justify-center w-5 h-5"
></Checkbox>
</div>
<div class="mt-4 grid gap-1.5">
<div
class="flex items-start gap-3 rounded-xl border border-theme-800/60 bg-black/15 p-2.5"
>
<div
class="rounded-lg bg-primary/10 p-2 text-primary ring-1 ring-primary/15 ring-inset"
>
<Rocket class="size-4" />
</div>
<div class="min-w-0">
<p class="text-[13px] leading-tight font-semibold">One-click launch</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Checks for updates, applies the patch and starts osu!.
</p>
</div>
</div>
<div
class="flex items-start gap-3 rounded-xl border border-theme-800/60 bg-black/15 p-2.5"
>
<div
class="rounded-lg bg-primary/10 p-2 text-primary ring-1 ring-primary/15 ring-inset"
>
<Palette class="size-4" />
</div>
<div class="min-w-0">
<p class="text-[13px] leading-tight font-semibold">Make it yours</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Themes with images, videos and custom fonts.
</p>
</div>
</div>
<div
class="flex items-start gap-3 rounded-xl border border-theme-800/60 bg-black/15 p-2.5"
>
<div
class="rounded-lg bg-primary/10 p-2 text-primary ring-1 ring-primary/15 ring-inset"
>
<RefreshCw class="size-4" />
</div>
<div class="min-w-0">
<p class="text-[13px] leading-tight font-semibold">Always up to date</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Keeps launcher files in sync before every launch.
</p>
</div>
</div>
</div>
<div class="mt-auto flex items-start gap-2.5 pt-3">
<Info class="mt-0.5 size-3.5 shrink-0 text-primary" />
<p class="text-xs leading-relaxed text-muted-foreground">
Make sure osu! is installed and signed in before continuing.
</p>
</div>
</div>
{:else if stepIndex === 1}
<div class="flex min-h-[332px] flex-col">
<div>
<p class="text-[10px] font-semibold tracking-[0.2em] text-primary uppercase">
osu! installation
</p>
<h2 class="mt-1.5 text-2xl font-bold tracking-tight">Where's osu!?</h2>
<p class="mt-1.5 max-w-lg text-sm leading-relaxed text-muted-foreground">
{#if $platform === 'macos'}
Point us at your osu!.app wrapper, or the folder that contains osu!.exe.
{:else}
Point us at the folder that contains osu!.exe.
{/if}
</p>
</div>
<div class="mt-4 rounded-xl border p-4 transition-colors {statusBorderClass}">
<div class="flex items-start gap-3">
<div class="rounded-full p-2 {statusIconClass}">
{#if status === 'detecting'}
<LoaderCircle class="size-4 animate-spin" />
{:else if status === 'valid'}
<CircleCheckBig class="size-4" />
{:else if status === 'invalid'}
<CircleAlert class="size-4" />
{:else}
<Search class="size-4" />
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-semibold">{statusTitle}</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{statusDescription}
</p>
{#if osuInstallPath}
<div
class="mt-2.5 flex items-center gap-2 rounded-lg border border-theme-800/80 bg-theme-950/70 px-2.5 py-1.5"
>
<FolderOpen class="size-3.5 shrink-0 text-muted-foreground" />
<span
class="truncate font-mono text-[11px] text-theme-200"
title={osuInstallPath}>{osuInstallPath}</span
>
</div>
{/if}
</div>
</div>
<div class="mt-3.5 flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="border-theme-700 bg-theme-900/80 hover:bg-theme-800"
onclick={browseOsu}
disabled={selecting}
>
<FolderOpen />
Browse…
</Button>
<Button
variant="ghost"
size="sm"
class="text-muted-foreground hover:text-foreground"
onclick={detectOsu}
disabled={detecting || selecting}
>
<RefreshCw class={detecting ? 'animate-spin' : ''} />
Scan again
</Button>
</div>
</div>
{#if $platform === 'macos'}
<div
class="mt-3 flex items-start gap-2.5 rounded-xl border border-primary/15 bg-primary/5 p-3"
>
<Info class="mt-0.5 size-3.5 shrink-0 text-primary" />
<p class="text-xs leading-relaxed text-theme-200">
Pick <code class="rounded bg-black/25 px-1 py-0.5 font-mono text-[11px]"
>osu!.app</code
> directly — you don't need to open its package contents. The launcher finds osu!
inside the Wineskin wrapper.
</p>
</div>
{/if}
</div>
{:else}
<div class="flex min-h-[332px] flex-col">
<div>
<p class="text-[10px] font-semibold tracking-[0.2em] text-primary uppercase">
Personalize
</p>
<h2 class="mt-1.5 text-2xl font-bold tracking-tight">Make it yours.</h2>
<p class="mt-1.5 max-w-lg text-sm leading-relaxed text-muted-foreground">
Choose how the launcher should look and behave.
</p>
</div>
<div
class="mt-3 divide-y divide-theme-800/50 rounded-xl border border-theme-800/60 bg-black/15"
>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Requires
.NET in your Wine prefix.{/if}
</p>
</div>
<Checkbox
id="wizard-patch"
class="mt-0.5"
checked={$patch}
onCheckedChange={(checked) => patch.set(checked)}
/>
</div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-custom-cursor"
>Lazer-style cursor</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Use a custom cursor in the launcher, like in osu!lazer.
</p>
</div>
<Checkbox
id="wizard-custom-cursor"
class="mt-0.5"
checked={$customCursor}
onCheckedChange={(checked) => {
if (!checked) cursorSmoothening.set(false);
customCursor.set(checked);
}}
/>
</div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-cursor-smoothing"
>Cursor smoothening</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Makes the custom cursor movement smoother.
</p>
</div>
<Checkbox
id="wizard-cursor-smoothing"
class="mt-0.5"
checked={$cursorSmoothening}
onCheckedChange={(checked) => {
if (!$customCursor) return;
cursorSmoothening.set(checked);
}}
disabled={!$customCursor}
/>
</div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-reduce-animations"
>Reduce animations</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Disables some launcher animations on low-end devices.
</p>
</div>
<Checkbox
id="wizard-reduce-animations"
class="mt-0.5"
checked={$reduceAnimations}
onCheckedChange={(checked) => reduceAnimations.set(checked)}
/>
</div>
<div class="flex items-start justify-between gap-6 px-4 py-1">
<div class="min-w-0">
<Label class="text-[13px]" for="wizard-presence"
>Discord Rich Presence</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Let other Discord users see what you are doing right now 👀
</p>
</div>
<div class="flex items-center gap-2.5">
{#if $presenceLoading}
<LoaderCircle class="size-3.5 animate-spin text-muted-foreground" />
{/if}
<Checkbox
id="wizard-presence"
class="mt-0.5"
bind:checked={$discordPresence}
disabled={$presenceLoading}
/>
</div>
</div>
</div>
</div>
{/if}
</div>
{/key}
</div>
<div
class="flex items-center justify-between gap-3 border-t border-theme-800/70 bg-black/20 px-5 py-3"
>
<Button
variant="ghost"
class={stepIndex === 0
? 'invisible text-muted-foreground'
: 'text-muted-foreground hover:text-foreground'}
onclick={previousStep}
disabled={stepIndex === 0}
>
<ArrowLeft />
Back
</Button>
<div class="flex items-center gap-3">
{#if stepIndex === 1 && osuInstallPath.length === 0 && !dev}
<span class="hidden text-[11px] text-muted-foreground sm:inline">
Select an installation to continue
</span>
{/if}
<Button class="group" onclick={nextStep} disabled={!canContinue}>
{stepIndex === 0 ? 'Get started' : 'Continue'}
<ArrowRight class="transition-transform group-hover:translate-x-0.5" />
</Button>
</div>
</div>
{/if}
<div class="mt-auto flex flex-row items-center justify-between">
<Button
class="bg-theme-950 hover:bg-theme-800"
variant="outline"
onclick={() => (selectedStep = Math.max(selectedStep - 1, 1))}
disabled={selectedStep <= 1}>Previous</Button
>
<Button
onclick={() => {
if (selectedStep >= steps.length) wizardFinished = true;
else selectedStep = Math.min(selectedStep + 1, steps.length);
}}
disabled={selectedStep > steps.length ||
(selectedStep === 2 && !dev && osuInstallPath.length <= 0)}
>{selectedStep >= steps.length ? 'Finish' : 'Next'}</Button
>
</div>
<p class="mt-3 text-center text-[11px] text-muted-foreground">
You can change all of this later in Settings.
</p>
</div>
</div>
{/if}