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:
1 parent
417f54a94a
commit
87202de656
97 files changed
+10968
-1422
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 +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}
|
||||
/>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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}`]);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`;
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+622
-299
@@ -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}
|
||||
Reference in new issue
Block a user