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