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

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

No files matched your search

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