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

+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}