feat(theme-v2): add detachable header widget, fix preview thumbnail rendering, and improve wineskin launch

- Support detaching title header to canvas with full, compact, and minimal variants
- Render detached widgets and respect chrome layout configuration in theme preview generator
- Fix macOS Wineskin launching by adding Frameworks to DYLD_FALLBACK_LIBRARY_PATH
- Fix launch button interactive handling, scaling margins, and icon-only states
- Improve player widget styling and variant display
This commit is contained in:
HorizonCode committed 2026-10-31 21:00:14 +01:00
1 parent 386c829455
commit 7648d1fd4c
10 files changed
+4190 -488

No files matched your search

+647 -35
View File
@@ -71,18 +71,26 @@
createDefaultThemeV2,
createThemeV2Behavior,
createThemeV2Element,
normalizeChrome,
normalizeThemeV2,
themeAssetName,
themeHasLaunchButton,
type ThemeV2Asset,
type ThemeV2Background,
type ThemeV2BehaviorTarget,
type ThemeV2ButtonAction,
type ThemeV2Chrome,
type ThemeV2Definition,
type ThemeV2Element,
type ThemeV2Font,
type ThemeV2Group,
type ThemeV2ImageElement,
type ThemeV2LayoutMode,
type ThemeV2LottieElement,
type ThemeV2ScriptValue,
type ThemeV2VideoElement,
type ThemeV2WidgetElement,
type ThemeV2WidgetKind,
} from '$lib/themeV2';
import {
exportThemeFolder,
@@ -94,7 +102,7 @@
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
import { currentUser } from '$lib/userAuthentication';
import { osuInstallationPath } from '$lib/userSettings';
import { formatBytes } from '$lib/utils';
import { formatBytes, openURL } from '$lib/utils';
import {
AlertTriangle,
Boxes,
@@ -105,6 +113,7 @@
ChevronUp,
ChevronsDown,
ChevronsUp,
CircleUser,
ClipboardPaste,
Clock,
Code2,
@@ -121,6 +130,7 @@
Image as ImageIcon,
Keyboard,
Layers,
LayoutGrid,
LoaderCircle,
Lock,
Maximize2,
@@ -230,6 +240,18 @@
let definition = $state<ThemeV2Definition>(createDefaultThemeV2());
let pickedAssets = $state<ThemeV2Asset[]>([]);
let lastImageBackground = $state<Extract<ThemeV2Background, { type: 'image' }> | undefined>(
undefined
);
let lastVideoBackground = $state<Extract<ThemeV2Background, { type: 'video' }> | undefined>(
undefined
);
let lastColorBackground = $state<Extract<ThemeV2Background, { type: 'color' }> | undefined>(
undefined
);
let lastGradientBackground = $state<Extract<ThemeV2Background, { type: 'gradient' }> | undefined>(
undefined
);
let assetBase = $state('');
let savedFolder = $state<string | undefined>(undefined);
let editorFolderPath = $state<string | undefined>(undefined);
@@ -292,6 +314,46 @@
const elements = $derived(definition.elements ?? []);
const groups = $derived(definition.groups ?? []);
const behaviors = $derived(definition.behaviors ?? []);
const themeChrome = $derived(
definition.chrome ?? normalizeChrome(undefined, definition.elements)
);
const hasValidLaunchButton = $derived(themeHasLaunchButton(definition));
function updateChrome<K extends keyof ThemeV2Chrome>(key: K, value: ThemeV2Chrome[K]) {
const current = definition.chrome ?? normalizeChrome(undefined, definition.elements);
definition.chrome = {
...current,
[key]: value,
};
}
function setLayoutMode(mode: ThemeV2LayoutMode) {
const current = definition.chrome ?? normalizeChrome(undefined, definition.elements);
if (mode === 'dashboard') {
definition.chrome = {
...current,
layoutMode: 'dashboard',
playerCard: true,
recentActivity: true,
pills: true,
dock: true,
};
} else if (mode === 'classic') {
definition.chrome = {
...current,
layoutMode: 'classic',
playerCard: true,
recentActivity: false,
pills: false,
dock: true,
};
} else {
definition.chrome = {
...current,
layoutMode: 'custom',
};
}
}
const logicBadges = $derived.by(() => {
const badges = new SvelteMap<string, { count: number; warning: boolean }>();
@@ -814,17 +876,24 @@
if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => ({
...theme,
typography: {
body: theme.typography?.body ?? 'Sora Variable',
heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable',
...(theme.typography?.mono ? { mono: theme.typography.mono } : {}),
},
fonts: theme.fonts ?? [],
elements: theme.elements ?? [],
groups: theme.groups ?? [],
});
const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => {
if (theme.background.type === 'image' && theme.background.src && !lastImageBackground) {
lastImageBackground = { ...theme.background };
} else if (theme.background.type === 'video' && theme.background.src && !lastVideoBackground) {
lastVideoBackground = { ...theme.background };
}
return {
...theme,
typography: {
body: theme.typography?.body ?? 'Sora Variable',
heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable',
...(theme.typography?.mono ? { mono: theme.typography.mono } : {}),
},
fonts: theme.fonts ?? [],
elements: theme.elements ?? [],
groups: theme.groups ?? [],
};
};
type EditorSnapshot = {
definition: ThemeV2Definition;
@@ -1899,43 +1968,81 @@
function setBackgroundType(type: ThemeV2Definition['background']['type']) {
const current = definition.background;
// Cache the active background before switching
if (current.type === 'image' && current.src) {
lastImageBackground = { ...current };
} else if (current.type === 'video' && current.src) {
lastVideoBackground = { ...current };
} else if (current.type === 'color') {
lastColorBackground = { ...current };
} else if (current.type === 'gradient') {
lastGradientBackground = { ...current };
}
const opacity = current.opacity ?? 1;
const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0;
const fit =
current.type === 'image' || current.type === 'video' ? (current.fit ?? 'cover') : 'cover';
if (type === 'color') {
definition.background = {
type,
color: current.type === 'color' ? current.color : '#181825',
opacity,
type: 'color',
color: current.type === 'color' ? current.color : (lastColorBackground?.color ?? '#181825'),
opacity: lastColorBackground?.opacity ?? opacity,
};
} else if (type === 'gradient') {
definition.background = {
type,
from: current.type === 'gradient' ? current.from : '#181825',
to: current.type === 'gradient' ? current.to : '#3b1d4f',
angle: current.type === 'gradient' ? (current.angle ?? 135) : 135,
opacity,
type: 'gradient',
from:
current.type === 'gradient' ? current.from : (lastGradientBackground?.from ?? '#181825'),
to: current.type === 'gradient' ? current.to : (lastGradientBackground?.to ?? '#3b1d4f'),
angle:
current.type === 'gradient'
? (current.angle ?? 135)
: (lastGradientBackground?.angle ?? 135),
opacity: lastGradientBackground?.opacity ?? opacity,
};
} else if (type === 'video') {
const candidateVideo =
current.type === 'video' && current.src
? current.src
: lastVideoBackground?.src ||
pickedAssets.find((a) => /\.(mp4|webm|ogv|mov)$/i.test(a.name))?.name ||
'';
definition.background = {
type,
src: current.type === 'video' ? current.src : '',
fit,
opacity,
blur,
muted: current.type === 'video' ? current.muted !== false : true,
type: 'video',
src: candidateVideo,
fit: current.type === 'video' ? fit : (lastVideoBackground?.fit ?? fit),
opacity: current.type === 'video' ? opacity : (lastVideoBackground?.opacity ?? opacity),
blur: current.type === 'video' ? blur : (lastVideoBackground?.blur ?? blur),
muted:
current.type === 'video' ? current.muted !== false : lastVideoBackground?.muted !== false,
};
if (candidateVideo) {
lastVideoBackground = { ...definition.background };
}
definition.audio = undefined;
} else {
const candidateImage =
current.type === 'image' && current.src
? current.src
: lastImageBackground?.src ||
pickedAssets.find((a) => /\.(png|jpg|jpeg|webp|gif|avif)$/i.test(a.name))?.name ||
'';
definition.background = {
type,
src: current.type === 'image' ? current.src : '',
fit,
opacity,
blur,
type: 'image',
src: candidateImage,
fit: current.type === 'image' ? fit : (lastImageBackground?.fit ?? fit),
opacity: current.type === 'image' ? opacity : (lastImageBackground?.opacity ?? opacity),
blur: current.type === 'image' ? blur : (lastImageBackground?.blur ?? blur),
};
if (candidateImage) {
lastImageBackground = { ...definition.background };
}
}
if (type === 'video') definition.audio = undefined;
}
async function chooseBackground(type: 'image' | 'video') {
@@ -1947,7 +2054,7 @@
const opacity = current.opacity ?? 1;
const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0;
if (type === 'video') {
definition.background = {
const bg: Extract<ThemeV2Background, { type: 'video' }> = {
type,
src: asset.name,
fit,
@@ -1955,14 +2062,18 @@
blur,
muted: current.type === 'video' ? current.muted !== false : true,
};
definition.background = bg;
lastVideoBackground = { ...bg };
} else {
definition.background = {
const bg: Extract<ThemeV2Background, { type: 'image' }> = {
type,
src: asset.name,
fit,
opacity,
blur,
};
definition.background = bg;
lastImageBackground = { ...bg };
}
}
@@ -2101,8 +2212,36 @@
| 'particles'
| 'clock'
| 'countdown'
| 'widget',
widgetKind: ThemeV2WidgetKind = 'launch-button'
) {
const element = createThemeV2Element(type, elements.length);
if (element.type === 'widget') {
element.widget = widgetKind;
if (widgetKind === 'player-card') {
element.width = 380;
element.height = 230;
} else if (widgetKind === 'avatar') {
element.width = 72;
element.height = 72;
element.variant = 'circle';
element.showCountry = true;
element.showLevel = true;
element.showOnlineRing = true;
} else if (widgetKind === 'recent-activity') {
element.width = 340;
element.height = 230;
} else if (widgetKind === 'telemetry') {
element.width = 320;
element.height = 42;
} else if (widgetKind === 'header') {
element.width = 340;
element.height = 64;
} else if (widgetKind === 'launch-button') {
element.width = 210;
element.height = 48;
}
}
if (selectedGroupId) element.groupId = selectedGroupId;
definition.elements = [...elements, element];
selectedGroupId = undefined;
@@ -2110,6 +2249,188 @@
leftTab = 'layers';
}
function handleDetachWidget(widgetKind: ThemeV2WidgetKind) {
const newElements = [...(definition.elements ?? [])];
const currentChrome = definition.chrome ?? normalizeChrome(undefined, newElements);
if (widgetKind === 'header') {
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'header');
if (!existing) {
const headerWidget = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
headerWidget.widget = 'header';
headerWidget.name = 'Header Title';
headerWidget.x = 88;
headerWidget.y = 28;
headerWidget.width = 320;
headerWidget.height = 64;
newElements.push(headerWidget);
existing = headerWidget;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
header: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'launch-button') {
let existing = newElements.find(
(el) =>
(el.type === 'widget' && el.widget === 'launch-button') ||
(el.type === 'button' && el.action.type === 'launch')
);
if (!existing) {
const launchBtn = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
launchBtn.widget = 'launch-button';
launchBtn.name = 'Launch Button';
launchBtn.x = 740;
launchBtn.y = 575;
launchBtn.width = 210;
launchBtn.height = 48;
newElements.push(launchBtn);
existing = launchBtn;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
dock: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'player-card') {
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'player-card');
if (!existing) {
const playerCard = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
playerCard.widget = 'player-card';
playerCard.name = 'Player Card';
playerCard.x = 100;
playerCard.y = 150;
playerCard.width = 380;
playerCard.height = 230;
newElements.push(playerCard);
existing = playerCard;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
playerCard: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'avatar') {
let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'avatar');
if (!existing) {
const avatarWidget = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
avatarWidget.widget = 'avatar';
avatarWidget.name = 'Player Avatar';
avatarWidget.x = 80;
avatarWidget.y = 80;
avatarWidget.width = 72;
avatarWidget.height = 72;
avatarWidget.variant = 'circle';
avatarWidget.showCountry = true;
avatarWidget.showLevel = true;
avatarWidget.showOnlineRing = true;
newElements.push(avatarWidget);
existing = avatarWidget;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
} else if (widgetKind === 'recent-activity') {
let existing = newElements.find(
(el) => el.type === 'widget' && el.widget === 'recent-activity'
);
if (!existing) {
const recentActivity = createThemeV2Element(
'widget',
newElements.length
) as ThemeV2WidgetElement;
recentActivity.widget = 'recent-activity';
recentActivity.name = 'Recent Activity';
recentActivity.x = 510;
recentActivity.y = 150;
recentActivity.width = 340;
recentActivity.height = 230;
newElements.push(recentActivity);
existing = recentActivity;
}
definition.chrome = {
...currentChrome,
layoutMode: 'custom',
recentActivity: false,
};
definition.elements = newElements;
selectedIds = [existing.id];
leftTab = 'layers';
}
}
function detachAllToCanvas() {
handleDetachWidget('header');
handleDetachWidget('launch-button');
handleDetachWidget('player-card');
handleDetachWidget('recent-activity');
}
function restoreNativeDock() {
const currentChrome = definition.chrome ?? normalizeChrome(undefined, definition.elements);
definition.chrome = {
...currentChrome,
layoutMode: 'dashboard',
dock: true,
playerCard: true,
recentActivity: true,
header: true,
};
}
function handleTestButtonAction(action: ThemeV2ButtonAction) {
if (action.type === 'navigate') {
sileo.info({
title: 'Button Action Test',
description: `Navigates to screen "${action.screen}" in launcher.`,
});
} else if (action.type === 'launch') {
sileo.info({
title: 'Button Action Test',
description: 'Launches osu! game.',
});
} else if (action.type === 'url') {
if (action.url) {
sileo.info({
title: 'Button Action Test',
description: `Opens ${action.url}`,
});
void openURL(action.url);
} else {
sileo.warning({
title: 'Button Action Test',
description: 'No URL specified',
});
}
}
}
async function addLottie() {
const asset = await chooseAsset('lottie');
if (!asset) return;
@@ -2677,6 +2998,11 @@
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(definition.version.trim())) {
warning('The version should use semantic versioning, for example 1.0.0.');
}
if (!themeHasLaunchButton(definition)) {
error(
'A launch button is required to launch osu!. Keep the bottom dock visible or add a launch button widget to your canvas.'
);
}
const background = definition.background;
if ((background.type === 'image' || background.type === 'video') && !background.src.trim()) {
@@ -3276,6 +3602,31 @@
<Timer />
Add countdown
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => addLayer('widget', 'launch-button')}
>
<LayoutGrid />
Add launch button widget
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => addLayer('widget', 'player-card')}
>
<LayoutGrid />
Add player card widget
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={() => addLayer('widget', 'avatar')}>
<CircleUser />
Add player avatar widget
</ContextMenu.Item>
<ContextMenu.Item
class="gap-2 py-1.5 text-[11px]"
onclick={() => addLayer('widget', 'recent-activity')}
>
<LayoutGrid />
Add recent activity widget
</ContextMenu.Item>
<ContextMenu.Item class="gap-2 py-1.5 text-[11px]" onclick={addLottie}>
<Film />
Add Lottie animation
@@ -3621,6 +3972,34 @@
<Timer />
Countdown
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'launch-button')}
>
<LayoutGrid />
Launch Button Widget
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'player-card')}
>
<LayoutGrid />
Player Card Widget
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'avatar')}
>
<CircleUser />
Player Avatar Widget
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 text-[11px]"
onclick={() => addLayer('widget', 'recent-activity')}
>
<LayoutGrid />
Recent Activity Widget
</DropdownMenu.Item>
<DropdownMenu.Item class="gap-2 text-[11px]" onclick={addLottie}>
<Film />
Lottie animation
@@ -4500,6 +4879,191 @@
onchange={setVariableDefault}
/>
<ThemeSection title="Launcher Layout & Chrome" contentClass="space-y-3 pt-2.5">
<div class="space-y-1.5">
<Label class={labelClass}>Layout Preset</Label>
<div class="grid grid-cols-3 gap-1 rounded-md bg-black/30 p-1">
{#each [{ value: 'dashboard', label: 'Dashboard' }, { value: 'classic', label: 'Classic' }, { value: 'custom', label: 'Custom' }] as mode (mode.value)}
<Button
variant="ghost"
size="sm"
class="h-7 px-1 text-[10px] {themeChrome.layoutMode === mode.value
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground'}"
onclick={() => setLayoutMode(mode.value as ThemeV2LayoutMode)}
>
{mode.label}
</Button>
{/each}
</div>
<p class="text-[10px] leading-relaxed text-muted-foreground">
{#if themeChrome.layoutMode === 'dashboard'}
Modern dashboard with large player card and recent activity highlights.
{:else if themeChrome.layoutMode === 'classic'}
Original minimal layout with compact bottom-left stats and open canvas.
{:else}
Custom freeform canvas: default cards are hidden so your layers take center stage.
{/if}
</p>
</div>
<!-- Freeform Moveable Elements Box -->
<div class="space-y-2 rounded-lg border border-primary/30 bg-primary/10 p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-theme-50">Moveable Canvas Elements</span>
<Badge variant="outline" class="border-primary/40 text-[9px] text-primary"
>Freeform</Badge
>
</div>
<p class="text-[10px] leading-relaxed text-theme-200">
Convert the Launch button, Player card, and Activity card into draggable layers so
you can position them anywhere.
</p>
<div class="flex flex-col gap-1.5 pt-1">
<Button
size="sm"
class="h-7 bg-primary text-xs font-semibold text-primary-foreground hover:bg-primary/90"
onclick={detachAllToCanvas}
>
✨ Detach All to Canvas
</Button>
<div class="grid grid-cols-2 gap-1 pt-0.5">
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('header')}
>
Move Header Title
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('launch-button')}
>
Move Launch Button
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('player-card')}
>
Move Player Card
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('avatar')}
>
Move Avatar
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-700 bg-theme-900/50 text-[10px] hover:bg-theme-800"
onclick={() => handleDetachWidget('recent-activity')}
>
Move Activity Card
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 text-[10px] text-muted-foreground hover:text-white"
onclick={restoreNativeDock}
>
Restore Dock
</Button>
</div>
</div>
</div>
<div class="space-y-2 border-t border-theme-800 pt-2">
<Label class="text-xs font-semibold text-theme-100">Component Visibility</Label>
<div class="space-y-2">
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Header Title & Greeting</span>
<Checkbox
checked={themeChrome.header !== false}
onCheckedChange={(c) => updateChrome('header', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Player Card</span>
<Checkbox
checked={themeChrome.playerCard !== false}
onCheckedChange={(c) => updateChrome('playerCard', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Recent Activity Card</span>
<Checkbox
checked={themeChrome.recentActivity !== false}
onCheckedChange={(c) => updateChrome('recentActivity', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Bottom Dock</span>
<Checkbox
checked={themeChrome.dock !== false}
onCheckedChange={(c) => updateChrome('dock', c === true)}
/>
</label>
<label
class="flex cursor-pointer items-center justify-between gap-2 text-[11px] text-theme-200"
>
<span>Dock Telemetry Chips</span>
<Checkbox
checked={themeChrome.dockTelemetry !== false}
onCheckedChange={(c) => updateChrome('dockTelemetry', c === true)}
/>
</label>
</div>
</div>
<!-- Launch Button Requirement Indicator -->
<div
class="rounded-md border p-2.5 text-[11px] {hasValidLaunchButton
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
: 'border-amber-500/30 bg-amber-500/10 text-amber-300'}"
>
<div class="flex items-center gap-1.5 font-bold">
{#if hasValidLaunchButton}
<CheckCircle2 class="size-3.5 text-emerald-400" />
<span>Launch button satisfied</span>
{:else}
<AlertTriangle class="size-3.5 text-amber-400" />
<span>Launch button required!</span>
{/if}
</div>
<p class="mt-1 text-[10px] leading-relaxed opacity-85">
{#if hasValidLaunchButton}
Theme includes osu! launch capability ({themeChrome.dock !== false
? 'via bottom dock'
: 'via canvas launch widget'}).
{:else}
osu! requires a launch button. Turn on the Bottom Dock or add a Launch Button
widget to the canvas.
{/if}
</p>
</div>
</ThemeSection>
<ThemeSection title="Background" contentClass="space-y-3 pt-2.5">
<div class="grid grid-cols-4 gap-1 rounded-md bg-black/30 p-1">
{#each backgroundTypes as option (option.value)}
@@ -4619,6 +5183,11 @@
definition.background.type === 'video'
) {
definition.background.blur = value;
if (definition.background.type === 'image' && lastImageBackground) {
lastImageBackground.blur = value;
} else if (definition.background.type === 'video' && lastVideoBackground) {
lastVideoBackground.blur = value;
}
}
}}
/>
@@ -4632,7 +5201,13 @@
definition.background.type === 'image' ||
definition.background.type === 'video'
) {
definition.background.fit = checked ? 'contain' : 'cover';
const fitVal = checked ? 'contain' : 'cover';
definition.background.fit = fitVal;
if (definition.background.type === 'image' && lastImageBackground) {
lastImageBackground.fit = fitVal;
} else if (definition.background.type === 'video' && lastVideoBackground) {
lastVideoBackground.fit = fitVal;
}
}
}}
/>
@@ -4651,6 +5226,9 @@
onCheckedChange={(checked) => {
if (definition.background.type === 'video') {
definition.background.muted = checked === true;
if (lastVideoBackground) {
lastVideoBackground.muted = checked === true;
}
}
}}
/>
@@ -5157,6 +5735,8 @@
onpatchgroup={(id, patch) => patchGroup(id, patch)}
oncontext={(id) => prepareContextMenu(id)}
oncontextgroup={(id) => prepareGroupContextMenu(id)}
ondetach={handleDetachWidget}
onbuttonaction={handleTestButtonAction}
/>
</div>
</ContextMenu.Trigger>
@@ -5605,6 +6185,38 @@
<Film class="size-4 text-primary" />
Lottie
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'launch-button')}
>
<LayoutGrid class="size-4 text-primary" />
Launch Button
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'player-card')}
>
<LayoutGrid class="size-4 text-primary" />
Player Card
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'avatar')}
>
<CircleUser class="size-4 text-primary" />
Player Avatar
</Button>
<Button
variant="secondary"
class="h-auto flex-col gap-1 p-3 text-[10px]"
onclick={() => addLayer('widget', 'recent-activity')}
>
<LayoutGrid class="size-4 text-primary" />
Recent Activity
</Button>
</div>
<ThemeSection
@@ -18,6 +18,8 @@
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import type { ThemeFontChoice } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import { openURL } from '$lib/utils';
import { sileo } from 'sileo';
import {
THEME_DATA_FIELDS,
THEME_PARTICLE_PRESETS,
@@ -27,15 +29,20 @@
type ThemeV2Element,
type ThemeV2Group,
type ThemeV2MaskShape,
type ThemeV2WidgetKind,
type ThemeV2WidgetVariant,
} from '$lib/themeV2';
import {
CircleUser,
Clock,
Component,
Film,
Gauge,
Image as ImageIcon,
LayoutGrid,
Megaphone,
MousePointerClick,
Play,
RefreshCw,
Repeat,
Shapes,
@@ -198,6 +205,12 @@
<Clock class="size-4" />
{:else if element.type === 'countdown'}
<Timer class="size-4" />
{:else if element.type === 'widget'}
{#if element.widget === 'avatar'}
<CircleUser class="size-4" />
{:else}
<LayoutGrid class="size-4" />
{/if}
{:else}
<ImageIcon class="size-4" />
{/if}
@@ -1593,6 +1606,46 @@
</p>
</div>
{/if}
<div class="space-y-1.5 pt-1">
<Button
variant="outline"
size="sm"
class="w-full gap-2 border-theme-700 bg-theme-900/60 text-xs text-theme-100 hover:bg-theme-800"
onclick={() => {
if (element.action.type === 'navigate') {
sileo.info({
title: 'Button Action Test',
description: `Navigates to screen "${element.action.screen}"`,
});
} else if (element.action.type === 'launch') {
sileo.info({
title: 'Button Action Test',
description: 'Launches osu!',
});
} else if (element.action.type === 'url') {
if (element.action.url) {
sileo.info({
title: 'Button Action Test',
description: `Opens ${element.action.url}`,
});
void openURL(element.action.url);
} else {
sileo.warning({
title: 'Button Action Test',
description: 'No URL specified',
});
}
}
}}
>
<Play class="size-3.5 text-primary" />
Test Button Action
</Button>
<p class="text-[9px] leading-relaxed text-muted-foreground">
In editor mode, canvas clicks select layers for dragging. Test actions using this button, or Shift+click on canvas.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-button-icon" class={label}>Icon</Label>
@@ -2165,6 +2218,296 @@
</div>
</div>
</ThemeSection>
{:else if element.type === 'widget'}
<ThemeSection title="Widget" contentClass="space-y-3 pt-2.5">
<div class="space-y-1.5">
<Label for="element-widget-type" class={label}>Widget Type</Label>
<Select.Root
type="single"
value={element.widget}
onValueChange={(val) => {
if (val) {
element.widget = val as ThemeV2WidgetKind;
if (val === 'player-card') {
element.width = 380;
element.height = 180;
} else if (val === 'avatar') {
element.width = 72;
element.height = 72;
if (!element.variant) element.variant = 'circle';
} else if (val === 'recent-activity') {
element.width = 320;
element.height = 180;
} else if (val === 'telemetry') {
element.width = 320;
element.height = 42;
} else if (val === 'header') {
element.width = 320;
element.height = 64;
} else if (val === 'launch-button') {
element.width = 200;
element.height = 52;
}
}
}}
>
<Select.Trigger
id="element-widget-type"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.widget === 'launch-button'
? 'Launch Button'
: element.widget === 'player-card'
? 'Player Card'
: element.widget === 'avatar'
? 'Player Avatar'
: element.widget === 'recent-activity'
? 'Recent Activity'
: element.widget === 'telemetry'
? 'Telemetry Bar'
: 'Header Title'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="launch-button" label="Launch Button" />
<Select.Item value="player-card" label="Player Card" />
<Select.Item value="avatar" label="Player Avatar" />
<Select.Item value="recent-activity" label="Recent Activity" />
<Select.Item value="telemetry" label="Telemetry Bar" />
<Select.Item value="header" label="Header Title" />
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="element-widget-variant" class={label}>Variant</Label>
<Select.Root
type="single"
value={element.variant ??
(element.widget === 'avatar'
? 'circle'
: element.widget === 'launch-button'
? 'primary'
: 'full')}
onValueChange={(val) => {
if (val) {
element.variant = val as ThemeV2WidgetVariant;
if (element.widget === 'player-card') {
if (val === 'minimal') {
element.width = 280;
element.height = 64;
} else if (val === 'compact') {
element.width = 340;
element.height = 120;
} else if (val === 'full') {
element.width = 380;
element.height = 200;
}
} else if (element.widget === 'recent-activity') {
if (val === 'minimal') {
element.width = 280;
element.height = 64;
} else if (val === 'compact') {
element.width = 340;
element.height = 120;
} else if (val === 'full') {
element.width = 340;
element.height = 200;
}
} else if (element.widget === 'launch-button') {
if (val === 'icon-only') {
element.width = 48;
element.height = 48;
} else if (val === 'compact') {
element.width = 140;
element.height = 42;
} else if (val === 'primary') {
element.width = 210;
element.height = 48;
}
} else if (element.widget === 'header') {
if (val === 'minimal') {
element.width = 240;
element.height = 42;
} else if (val === 'compact') {
element.width = 380;
element.height = 48;
} else if (val === 'full') {
element.width = 320;
element.height = 64;
}
}
}
}}
>
<Select.Trigger
id="element-widget-variant"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{#if element.widget === 'avatar'}
{element.variant === 'rounded'
? 'Rounded Square'
: element.variant === 'square'
? 'Square'
: 'Circle'}
{:else if element.widget === 'launch-button'}
{element.variant === 'compact'
? 'Compact (Pill)'
: element.variant === 'icon-only'
? 'Icon-Only (Circle)'
: 'Primary (Capsule)'}
{:else}
{element.variant === 'compact'
? 'Compact'
: element.variant === 'minimal' || element.variant === 'icon-only'
? 'Minimal'
: 'Full'}
{/if}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#if element.widget === 'avatar'}
<Select.Item value="circle" label="Circle" />
<Select.Item value="rounded" label="Rounded Square" />
<Select.Item value="square" label="Square" />
{:else if element.widget === 'launch-button'}
<Select.Item value="primary" label="Primary (Capsule)" />
<Select.Item value="compact" label="Compact (Pill)" />
<Select.Item value="icon-only" label="Icon-Only (Circle)" />
{:else}
<Select.Item value="full" label="Full" />
<Select.Item value="compact" label="Compact" />
<Select.Item value="minimal" label="Minimal" />
{/if}
</Select.Content>
</Select.Root>
</div>
{#if element.widget === 'avatar'}
<div class="space-y-2 border-t border-theme-800 pt-2">
<Label class={label}>Avatar Display Options</Label>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Checkbox
id="element-avatar-country"
checked={element.showCountry !== false}
onCheckedChange={(checked) => (element.showCountry = checked === true)}
/>
<Label
for="element-avatar-country"
class="text-[11px] font-normal text-muted-foreground"
>
Show Country Flag Badge
</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="element-avatar-level"
checked={element.showLevel !== false}
onCheckedChange={(checked) => (element.showLevel = checked === true)}
/>
<Label
for="element-avatar-level"
class="text-[11px] font-normal text-muted-foreground"
>
Show Level Badge
</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="element-avatar-ring"
checked={element.showOnlineRing !== false}
onCheckedChange={(checked) => (element.showOnlineRing = checked === true)}
/>
<Label
for="element-avatar-ring"
class="text-[11px] font-normal text-muted-foreground"
>
Online Status Glow Ring
</Label>
</div>
</div>
</div>
{/if}
<!-- Quick Snap Position Presets for Widgets -->
<div class="space-y-1.5 border-t border-theme-800 pt-2">
<Label class={label}>Quick Position Snaps</Label>
<div class="grid grid-cols-2 gap-1">
<Button
variant="outline"
size="sm"
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
onclick={() => {
element.x = 1000 - element.width - 24;
element.y = 650 - element.height - 24;
}}
>
↘ Bottom-Right
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
onclick={() => {
element.x = Math.round((1000 - element.width) / 2);
element.y = 650 - element.height - 24;
}}
>
↓ Bottom-Center
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
onclick={() => {
element.x = 24;
element.y = 650 - element.height - 24;
}}
>
↙ Bottom-Left
</Button>
<Button
variant="outline"
size="sm"
class="h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
onclick={() => {
element.x = 1000 - element.width - 24;
element.y = 24;
}}
>
↗ Top-Right
</Button>
<Button
variant="outline"
size="sm"
class="col-span-2 h-6 border-theme-800 bg-theme-900/40 text-[10px] hover:bg-theme-800"
onclick={() => {
element.x = Math.round((1000 - element.width) / 2);
element.y = Math.round((650 - element.height) / 2);
}}
>
⊙ Center on Canvas
</Button>
</div>
</div>
<p class="text-[9px] leading-relaxed text-muted-foreground">
{#if element.widget === 'launch-button'}
Interactive game launch button. Satisfies the required Launch button condition.
{:else if element.widget === 'player-card'}
Live stats card displaying rank, pp, accuracy, level, and playcount.
{:else if element.widget === 'avatar'}
Player avatar with live profile image, shape styles, country badge, and online ring.
{:else if element.widget === 'recent-activity'}
Highlights the player's latest play, beatmap cover art, and grade badge.
{:else if element.widget === 'telemetry'}
Pill bar showing beatmap counts, skin counts, ping, and osu! build.
{:else if element.widget === 'header'}
Launcher title and dynamic greeting. Full shows title and subtitle, Compact shows an inline pill greeting, and Minimal displays title only.
{:else}
Launcher title and dynamic greeting.
{/if}
</p>
</ThemeSection>
{/if}
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
+281 -76
View File
@@ -3,16 +3,25 @@
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { currentUserInfo } from '$lib/data';
import { scopeThemeCss } from '$lib/themeCssScope';
import { fontStack, monoFontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
import {
normalizeChrome,
readableTextColor,
themeFontFaceCss,
themeHasLaunchButton,
type ThemeV2ButtonAction,
type ThemeV2Definition,
type ThemeV2Group,
type ThemeV2ScriptValue,
type ThemeV2WidgetKind,
} from '$lib/themeV2';
import { currentUser } from '$lib/userAuthentication';
import { countryCodeToEmoji } from '$lib/utils';
import { Clock, Trophy } from '@lucide/svelte';
let {
definition,
@@ -31,6 +40,8 @@
onpatchgroup,
oncontext,
oncontextgroup,
ondetach,
onbuttonaction,
}: {
definition: ThemeV2Definition;
assets?: string;
@@ -48,6 +59,8 @@
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
oncontext?: (id: string | undefined, event: MouseEvent) => void;
oncontextgroup?: (id: string, event: MouseEvent) => void;
ondetach?: (widgetKind: ThemeV2WidgetKind) => void;
onbuttonaction?: (action: ThemeV2ButtonAction) => void;
} = $props();
const bodyFont = $derived(definition.typography?.body);
@@ -56,6 +69,8 @@
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
const accentText = $derived(readableTextColor(accent));
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
const chrome = $derived(definition.chrome ?? normalizeChrome(undefined, definition.elements));
const hasLaunchBtn = $derived(themeHasLaunchButton(definition));
const stageStyle = $derived(
[
`font-family:${fontStack(bodyFont)}`,
@@ -124,94 +139,283 @@
</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">
<section class="relative flex min-w-0 flex-col justify-between">
{#if chrome.header !== false}
<div class="px-8 pt-7 pb-2 shrink-0 z-10 flex items-start justify-between gap-4">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<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"
class="group/header relative flex flex-col {interactive
? 'cursor-pointer rounded-xl p-2 -m-2 transition-all hover:bg-white/5 hover:ring-2 hover:ring-primary/40'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('header');
}
}}
title={interactive ? 'Click to detach into moveable Header Title layer' : undefined}
>
<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">
<span
class="text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
style="font-family:{fontStack(headingFont, bodyFont)}"
>
EZPPLauncher
</span>
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
Good evening, Guest!
</span>
{#if interactive}
<div
class="flex h-7 w-full items-center justify-center rounded-md bg-primary text-xs font-medium text-primary-foreground"
class="pointer-events-none absolute -bottom-6 left-0 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/header:opacity-100"
>
Log In
Click to detach & move freely
</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>
{/if}
</div>
</div>
{:else}
<div></div>
{/if}
<div class="px-8 py-2 my-auto z-10">
{#if chrome.layoutMode === 'dashboard'}
<div class="flex flex-row items-stretch gap-5 w-full max-w-4xl">
{#if chrome.playerCard !== false}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="flex-1 min-w-[320px] max-w-[440px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-4 flex flex-col justify-between gap-3 shadow-xl {interactive
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('player-card');
}
}}
title={interactive ? 'Click to detach into moveable Player Card layer' : undefined}
>
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<Avatar.Root
class="size-11 rounded-xl border border-white/15 bg-theme-900 shadow"
>
<Avatar.Image
src="https://a.ez-pp.farm/{$currentUser?.id ??
$currentUserInfo?.info?.id ??
0}"
alt={$currentUser?.name ?? 'Player'}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={20} />
</Avatar.Fallback>
</Avatar.Root>
{#if $currentUserInfo?.info?.country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
>
{countryCodeToEmoji($currentUserInfo.info.country)}
</span>
{/if}
</div>
<div>
<span class="font-bold text-sm text-theme-50 block"
>{$currentUser?.name ?? 'Guest Player'}</span
>
<span class="text-xs text-muted-foreground block">
Lv.{$currentUserInfo?.stats?.['0']?.level ?? 98} · {(
$currentUserInfo?.stats?.['0']?.acc ?? 93
).toFixed(1)}%
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="bg-white/5 rounded-xl p-2.5">
<span class="text-[10px] text-muted-foreground block">Rank</span>
<span class="font-bold text-sm text-theme-50">#102</span>
</div>
<div class="bg-white/5 rounded-xl p-2.5">
<span class="text-[10px] text-muted-foreground block">Performance</span>
<span class="font-bold text-sm text-primary">4,521pp</span>
</div>
</div>
</div>
{/if}
{#if chrome.recentActivity !== false}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="flex-1 min-w-[300px] max-w-[380px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden shadow-xl flex flex-col justify-between {interactive
? 'cursor-pointer hover:ring-2 hover:ring-primary/40 transition-all'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('recent-activity');
}
}}
title={interactive
? 'Click to detach into moveable Recent Activity layer'
: undefined}
>
<!-- Mini Cover Header -->
<div class="relative h-20 w-full shrink-0 overflow-hidden bg-theme-900/80">
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
></div>
<div class="absolute top-2 left-2.5 right-2.5 flex items-center justify-between">
<span
class="rounded-full border border-white/10 bg-black/60 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-white/90"
>
Recent Activity
</span>
<span
class="rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
>
S
</span>
</div>
<div class="absolute bottom-1.5 left-2.5 right-2.5 flex min-w-0 flex-col">
<span class="truncate text-xs font-bold text-white">Space Pirates</span>
<span class="truncate text-[10px] text-white/70">Waterflame</span>
</div>
</div>
<!-- Metrics Body -->
<div class="flex flex-1 flex-col justify-center gap-2 p-2.5">
<div class="flex items-center justify-between gap-1 text-[11px]">
<span class="max-w-[180px] truncate font-semibold text-primary">
[Galactic Thieves]
</span>
<div
class="flex items-center gap-1 rounded-md border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
>
<Trophy class="size-3" />
<span>Rank #1</span>
</div>
</div>
<div
class="flex items-center justify-between border-t border-white/5 pt-1.5 text-[10px] text-muted-foreground"
>
<span class="flex items-center gap-1 font-mono">
<Clock class="size-2.5" />
5d ago
</span>
<span class="text-white/60">ez-pp.farm</span>
</div>
</div>
</div>
{/if}
</div>
{:else if chrome.layoutMode === 'classic'}
{#if chrome.playerCard !== false}
<div class="flex flex-col justify-end">
<div
class="w-72 h-48 bg-black/40 backdrop-blur-sm rounded-md ring-1 ring-inset ring-white/5 flex flex-col items-center justify-center p-3 gap-3"
>
<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.</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>
</div>
</div>
{/if}
{/if}
</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">
<ThemeIcon name="music" set={iconSet} 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">
<ThemeIcon name="brush" set={iconSet} 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">
<ThemeIcon name="wifi" set={iconSet} 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"
>
<ThemeIcon
name="launch"
set={iconSet}
class="ml-0.5 size-4 fill-current text-primary-foreground"
/>
{#if chrome.dock !== false}
<div
class="relative z-10 flex items-center justify-between border-t border-t-theme-900 bg-black/40 px-8 py-3.5 backdrop-blur-sm shrink-0"
>
{#if chrome.dockTelemetry !== false}
<div class="flex items-center gap-3">
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
>
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
<span>1.2k</span>
</div>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
>
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
<span>42</span>
</div>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5 text-[11px] text-white/80"
>
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
<span>19ms</span>
</div>
</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>
{:else}
<div></div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="group/launch relative flex h-11 items-center rounded-full bg-primary shadow-lg shadow-primary/25 {interactive
? 'cursor-pointer hover:ring-2 hover:ring-white/40 transition-all'
: ''}"
role={interactive ? 'button' : undefined}
tabindex={interactive ? 0 : undefined}
onclick={(e) => {
if (interactive) {
e.stopPropagation();
ondetach?.('launch-button');
}
}}
title={interactive
? 'Click to detach and position Launch Button freely anywhere'
: undefined}
>
<div class="relative flex size-11 shrink-0 items-center justify-center">
<div
class="relative z-10 flex size-8 items-center justify-center rounded-full bg-black/30"
>
<ThemeIcon
name="launch"
set={iconSet}
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
/>
</div>
</div>
<div class="w-48 pl-1 pr-5 text-center">
<p class="text-xs font-bold leading-tight text-primary-foreground">Launch</p>
</div>
{#if interactive}
<div
class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded border border-white/20 bg-black/90 px-2 py-0.5 text-[10px] text-white opacity-0 shadow transition-opacity group-hover/launch:opacity-100"
>
Click to detach & move freely
</div>
{/if}
</div>
</div>
</div>
{:else if !hasLaunchBtn}
<div
class="fixed bottom-6 right-6 z-30 flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-300 backdrop-blur"
>
<span>⚠️ Launch Button Missing</span>
</div>
{/if}
</section>
</div>
@@ -238,5 +442,6 @@
{onpatchgroup}
{oncontext}
{oncontextgroup}
{onbuttonaction}
/>
</div>
+682 -6
View File
@@ -2,6 +2,9 @@
import ThemeCustomLayer from '$lib/components/theme/ThemeCustomLayer.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { currentUserInfo } from '$lib/data';
import { launching as launchingStore } from '$lib/global';
import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import { fontStack } from '$lib/themeFonts';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
@@ -39,7 +42,19 @@
type ThemeV2Point,
type ThemeV2ScriptValue,
} from '$lib/themeV2';
import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte';
import { currentUser } from '$lib/userAuthentication';
import { countryCodeToEmoji, formatRelativeTime, openURL } from '$lib/utils';
import {
Boxes,
ChevronRight,
Clock,
Medal,
Move,
RotateCw,
TrendingDown,
TrendingUp,
Trophy,
} from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { TextMorph } from 'torph/svelte';
@@ -62,6 +77,10 @@
visibility = {},
groupVisibility = {},
variables = {},
launching = undefined,
launchProgress = -1,
launchText = 'Launch',
launchDisabled = false,
oninteraction,
onselect,
onselectmany,
@@ -91,6 +110,10 @@
groupVisibility?: Record<string, boolean>;
/** Rule variables available to `{name}` placeholders in text layers. */
variables?: Record<string, ThemeV2ScriptValue>;
launching?: boolean;
launchProgress?: number;
launchText?: string;
launchDisabled?: boolean;
oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void;
onselect?: (id: string | undefined, additive?: boolean) => void;
onselectmany?: (ids: string[], additive?: boolean) => void;
@@ -103,6 +126,8 @@
onbuttonaction?: (action: ThemeV2ButtonAction) => void;
} = $props();
const isLaunching = $derived(launching !== undefined ? launching : $launchingStore);
const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
const ancestorsOf = (element: ThemeV2Element) => themeGroupAncestors(groups, element);
@@ -138,10 +163,18 @@
(element) =>
(element.type === 'data' && element.field.startsWith('clock.')) ||
element.type === 'clock' ||
element.type === 'countdown'
element.type === 'countdown' ||
(element.type === 'widget' && element.widget === 'header')
)
);
const timeGreeting = $derived.by(() => {
const hour = clockNow.getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
});
$effect(() => {
if (!usesClock) return;
const timer = window.setInterval(() => (clockNow = new Date()), 1000);
@@ -1560,9 +1593,13 @@
/>
{/key}
{:else if element.type === 'button'}
{@const isActionLaunch = element.action?.type === 'launch'}
<button
type="button"
class="theme-button flex h-full w-full items-center justify-center gap-2 px-4"
class="theme-button flex h-full w-full items-center justify-center gap-2 px-4 {isActionLaunch &&
isLaunching
? 'cursor-wait brightness-90'
: ''}"
style="--theme-button-hover:{element.hoverBackground ??
'#ff4d94'};background:{element.background ?? '#ff006e'};color:{element.color ??
'#ffffff'};border:{element.borderWidth
@@ -1570,16 +1607,31 @@
: 'none'};border-radius:{element.radius ?? 10}px;font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 16}px;font-weight:{element.fontWeight ?? 600};"
disabled={isActionLaunch && (launchDisabled || isLaunching)}
onclick={(event) => {
if (interactive) return;
if (interactive) {
if (event.shiftKey || event.ctrlKey || event.metaKey) {
event.stopPropagation();
onbuttonaction?.(element.action);
}
return;
}
if (isActionLaunch && (launchDisabled || isLaunching)) return;
event.stopPropagation();
triggerInteraction(element, 'click');
onbuttonaction?.(element.action);
}}
>
{#if element.icon}
{#if isActionLaunch && isLaunching}
<ThemeIcon name="loader" set={iconSet} size="1.1em" class="animate-spin" />
{:else if element.icon}
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
{/if}
{#if isMorphEnabled(element)}
{#if isActionLaunch && isLaunching}
<span class="truncate">
{launchProgress > 0 ? `${launchText} (${Math.round(launchProgress)}%)` : launchText}
</span>
{:else if isMorphEnabled(element)}
<TextMorph
text={String(interpolateThemeText(element.label, variables) ?? '')}
duration={morphDuration(element)}
@@ -1800,6 +1852,630 @@
</span>
{/if}
</div>
{:else if element.type === 'widget'}
<div class="pointer-events-auto h-full w-full select-none overflow-visible">
{#if element.widget === 'launch-button'}
{#if element.variant === 'icon-only' || element.variant === 'minimal'}
{@const btnRadius = Math.max(
12,
Math.round(Math.min(element.width, element.height) / 2) - 6
)}
{@const btnCircumference = 2 * Math.PI * btnRadius}
{@const strokeOffset =
launchProgress === -1
? btnCircumference * 0.7
: btnCircumference -
(Math.max(0, Math.min(100, launchProgress)) / 100) * btnCircumference}
<button
type="button"
class="relative flex h-full w-full items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 transition-all {isLaunching
? 'cursor-wait brightness-95'
: launchDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:scale-105 hover:brightness-110 active:scale-95'}"
disabled={launchDisabled || isLaunching}
onclick={(e) => {
e.stopPropagation();
if (!launchDisabled && !isLaunching) {
onbuttonaction?.({ type: 'launch' });
}
}}
title={isLaunching ? launchText : 'Launch osu!'}
>
{#if isLaunching}
<!-- Rotating progress ring SVG -->
<svg
class="absolute inset-0 h-full w-full {launchProgress === -1
? 'animate-spin'
: '-rotate-90'}"
viewBox="0 0 {element.width} {element.height}"
>
<circle
cx={element.width / 2}
cy={element.height / 2}
r={btnRadius}
fill="none"
stroke="rgba(0,0,0,0.25)"
stroke-width="3"
/>
<circle
cx={element.width / 2}
cy={element.height / 2}
r={btnRadius}
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray={btnCircumference}
stroke-dashoffset={strokeOffset}
class="transition-[stroke-dashoffset] duration-150"
/>
</svg>
{#if launchProgress > 0}
<span
class="relative z-10 text-[11px] font-black tracking-tight text-primary-foreground"
>
{Math.round(launchProgress)}%
</span>
{:else}
<ThemeIcon
name="loader"
set={iconSet}
class="relative z-10 size-6 animate-spin text-primary-foreground"
/>
{/if}
{:else}
<ThemeIcon name="launch" set={iconSet} class="size-6 fill-current ml-0.5" />
{/if}
</button>
{:else if element.variant === 'compact'}
<button
type="button"
class="relative flex h-full w-full items-center justify-center gap-2 rounded-full bg-primary px-3 text-xs font-bold text-primary-foreground shadow-md shadow-primary/25 transition-all {isLaunching
? 'cursor-wait brightness-95'
: launchDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]'}"
disabled={launchDisabled || isLaunching}
onclick={(e) => {
e.stopPropagation();
if (!launchDisabled && !isLaunching) {
onbuttonaction?.({ type: 'launch' });
}
}}
title={isLaunching ? launchText : 'Launch osu!'}
>
{#if isLaunching}
<ThemeIcon
name="loader"
set={iconSet}
class="size-3.5 animate-spin fill-current"
/>
<span>{launchProgress > 0 ? `${Math.round(launchProgress)}%` : launchText}</span
>
{:else}
<ThemeIcon name="launch" set={iconSet} class="size-3.5 fill-current" />
<span>Launch</span>
{/if}
</button>
{:else}
<button
type="button"
class="relative flex h-full w-full items-center justify-center gap-2.5 rounded-full bg-primary px-5 font-bold text-primary-foreground shadow-lg shadow-primary/30 transition-all {isLaunching
? 'cursor-wait brightness-95'
: launchDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]'}"
disabled={launchDisabled || isLaunching}
onclick={(e) => {
e.stopPropagation();
if (!launchDisabled && !isLaunching) {
onbuttonaction?.({ type: 'launch' });
}
}}
title={isLaunching ? launchText : 'Launch osu!'}
>
<div class="flex size-7 items-center justify-center rounded-full bg-black/20">
{#if isLaunching}
<ThemeIcon
name="loader"
set={iconSet}
class="size-3.5 animate-spin text-primary-foreground"
/>
{:else}
<ThemeIcon
name="launch"
set={iconSet}
class="ml-0.5 size-3.5 fill-current text-primary-foreground"
/>
{/if}
</div>
<span class="text-sm font-bold tracking-wide">
{isLaunching
? launchProgress > 0
? `${launchText} (${Math.round(launchProgress)}%)`
: launchText
: 'Launch'}
</span>
</button>
{/if}
{:else if element.widget === 'player-card'}
{@const playerStats =
$currentUserInfo?.stats?.['0'] ?? $themeGlobalData?.user?.stats?.osu}
{@const accuracyVal =
playerStats && 'acc' in playerStats
? playerStats.acc
: playerStats && 'accuracy' in playerStats
? playerStats.accuracy
: 98.3}
{@const playerId = $currentUser?.id ?? $currentUserInfo?.info?.id ?? 0}
{@const playerName = $currentUser?.name ?? 'Player'}
{@const country = $currentUserInfo?.info?.country}
{@const levelVal = playerStats && 'level' in playerStats ? playerStats.level : 1}
{@const levelProgress =
playerStats && 'level_progress' in playerStats ? playerStats.level_progress : 75}
{@const rankVal = playerStats?.rank ? playerStats.rank : 102}
{@const countryRankVal =
playerStats && 'country_rank' in playerStats ? playerStats.country_rank : undefined}
{@const ppVal = playerStats?.pp ? playerStats.pp : 4521}
{@const playsVal = playerStats?.plays
? Number(playerStats.plays).toLocaleString()
: '11,813'}
{#if element.variant === 'minimal'}
<div
class="flex h-full w-full items-center gap-2.5 rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="relative shrink-0">
<Avatar.Root
class="size-10 rounded-xl border border-white/15 bg-theme-900 shadow"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={18} />
</Avatar.Fallback>
</Avatar.Root>
{#if country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<span class="block truncate text-xs font-bold text-white">{playerName}</span>
<span class="block text-[10px] font-medium text-muted-foreground">
#{rankVal} · {ppVal}pp
</span>
</div>
<span
class="shrink-0 rounded-md bg-primary/20 px-1.5 py-0.5 text-[9px] font-bold text-primary"
>
Lv.{levelVal}
</span>
</div>
{:else if element.variant === 'compact'}
<div
class="flex h-full w-full flex-col justify-between rounded-2xl border border-white/10 bg-black/55 p-3 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<Avatar.Root
class="size-11 rounded-xl border border-white/15 bg-theme-900 shadow"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={20} />
</Avatar.Fallback>
</Avatar.Root>
{#if country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[9px] leading-none shadow"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="block truncate text-sm font-bold text-white">{playerName}</span
>
<span
class="rounded bg-primary/20 px-1.5 py-0.5 text-[9px] font-bold text-primary"
>
Lv.{levelVal}
</span>
</div>
<span class="block text-[10px] font-medium text-muted-foreground">
#{rankVal} · {ppVal}pp
</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-white/5 pt-2 text-xs">
<div
class="flex items-center justify-between rounded-lg bg-white/5 px-2.5 py-1"
>
<span class="text-[9px] font-medium text-muted-foreground">Accuracy</span>
<span class="text-[11px] font-bold text-white">{accuracyVal.toFixed(1)}%</span
>
</div>
<div
class="flex items-center justify-between rounded-lg bg-white/5 px-2.5 py-1"
>
<span class="text-[9px] font-medium text-muted-foreground">Plays</span>
<span class="text-[11px] font-bold text-white">{playsVal}</span>
</div>
</div>
</div>
{:else}
<div
class="flex h-full w-full flex-col justify-between rounded-2xl border border-white/10 bg-black/55 p-3.5 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<Avatar.Root
class="size-12 rounded-xl border border-white/15 bg-theme-900 shadow-md"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon name="user" set={iconSet} size={22} />
</Avatar.Fallback>
</Avatar.Root>
{#if country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[10px] leading-none shadow"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
</div>
<div class="min-w-0 flex flex-col">
<span class="block truncate text-sm font-bold text-white">{playerName}</span
>
<div class="flex items-center gap-2 mt-1">
<span class="text-[11px] font-semibold text-white/80 shrink-0">
Lv.{levelVal}
</span>
<div class="w-20 rounded-full bg-white/10 h-1.5 overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-all"
style="width: {levelProgress}%"
></div>
</div>
<span class="text-[10px] text-muted-foreground font-mono">
{levelProgress}%
</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-white/5 pt-2 mt-2">
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<div class="flex items-center justify-between">
<span class="text-[9px] font-medium text-muted-foreground">Rank</span>
{#if countryRankVal}
<span class="text-[9px] font-bold text-primary">#{countryRankVal}</span>
{/if}
</div>
<span class="text-xs font-bold text-white mt-0.5">#{rankVal}</span>
</div>
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<span class="text-[9px] font-medium text-muted-foreground">Performance</span>
<span class="text-xs font-bold text-primary mt-0.5">{ppVal}pp</span>
</div>
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<span class="text-[9px] font-medium text-muted-foreground">Accuracy</span>
<span class="text-xs font-bold text-white mt-0.5"
>{accuracyVal.toFixed(1)}%</span
>
</div>
<div class="rounded-xl bg-white/5 p-2 flex flex-col justify-center">
<span class="text-[9px] font-medium text-muted-foreground">Plays</span>
<span class="text-xs font-bold text-white mt-0.5">{playsVal}</span>
</div>
</div>
</div>
{/if}
{:else if element.widget === 'recent-activity'}
{@const event = $currentUserInfo?.events?.[0]}
{#if element.variant === 'minimal'}
<div
class="flex h-full w-full items-center gap-2.5 overflow-hidden rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-theme-50 shadow-xl backdrop-blur-md"
>
{#if event?.setId}
<img
src="https://assets.ppy.sh/beatmaps/{event.setId}/covers/cover.jpg"
alt={event.title}
class="size-10 shrink-0 rounded-lg object-cover"
/>
{:else}
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-theme-900 text-primary"
>
<Trophy class="size-4" />
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="block truncate text-xs font-bold text-white"
>{event?.title ?? 'Space Pirates'}</span
>
<span class="block truncate text-[10px] text-muted-foreground">
{event?.artist ?? 'Waterflame'} · {event
? formatRelativeTime(event.time)
: '5d ago'}
</span>
</div>
<span
class="shrink-0 rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
>
{event?.grade ?? 'S'}
</span>
</div>
{:else if element.variant === 'compact'}
<div
class="relative flex h-full w-full flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-black/55 text-theme-50 shadow-xl backdrop-blur-md"
>
<div class="relative h-14 w-full shrink-0 overflow-hidden bg-theme-900/80">
{#if event?.setId}
<img
src="https://assets.ppy.sh/beatmaps/{event.setId}/covers/cover.jpg"
alt={event.title}
class="h-full w-full object-cover"
/>
{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
></div>
<div class="absolute top-1.5 left-2 right-2 flex items-center justify-between">
<span class="text-[9px] font-bold uppercase tracking-wider text-white/90"
>Recent Activity</span
>
<span
class="rounded border border-amber-300/40 bg-amber-400/20 px-1 py-0.2 text-[9px] font-black text-amber-300"
>
{event?.grade ?? 'S'}
</span>
</div>
<div
class="absolute bottom-1 left-2 right-2 truncate text-xs font-bold text-white"
>
{event?.title ?? 'Space Pirates'}
</div>
</div>
<div class="flex items-center justify-between px-2.5 py-1.5 text-[10px]">
<span class="max-w-[180px] truncate font-semibold text-primary"
>[{event?.version ?? 'Galactic Thieves'}]</span
>
<span class="font-bold text-amber-300">Rank #{event?.rank ?? 1}</span>
</div>
</div>
{:else}
<div
class="relative flex h-full w-full flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-black/55 text-theme-50 shadow-xl backdrop-blur-md"
>
<!-- Cover Banner -->
<div class="relative h-20 w-full shrink-0 overflow-hidden bg-theme-900/80">
{#if event?.setId}
<img
src="https://assets.ppy.sh/beatmaps/{event.setId}/covers/cover.jpg"
alt={event.title}
class="h-full w-full object-cover"
/>
{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"
></div>
<div
class="absolute top-2 left-2.5 right-2.5 flex items-center justify-between"
>
<span
class="rounded-full border border-white/10 bg-black/60 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-white/90"
>
Recent Activity
</span>
<span
class="rounded border border-amber-300/40 bg-amber-400/20 px-1.5 py-0.5 text-[10px] font-black text-amber-300"
>
{event?.grade ?? 'S'}
</span>
</div>
<div class="absolute bottom-1.5 left-2.5 right-2.5 flex min-w-0 flex-col">
<span class="truncate text-xs font-bold text-white"
>{event?.title ?? 'Space Pirates'}</span
>
<span class="truncate text-[10px] text-white/70"
>{event?.artist ?? 'Waterflame'}</span
>
</div>
</div>
<!-- Metrics Body -->
<div class="flex flex-1 flex-col justify-center gap-2 p-2.5">
<div class="flex items-center justify-between gap-1 text-[11px]">
<span class="max-w-[180px] truncate font-semibold text-primary">
[{event?.version ?? 'Galactic Thieves'}]
</span>
<div
class="flex items-center gap-1 rounded-md border border-amber-400/20 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
>
<Trophy class="size-3" />
<span>Rank #{event?.rank ?? 1}</span>
</div>
</div>
<div
class="flex items-center justify-between border-t border-white/5 pt-1.5 text-[10px] text-muted-foreground"
>
<span class="flex items-center gap-1 font-mono">
<Clock class="size-2.5" />
{event ? formatRelativeTime(event.time) : '5d ago'}
</span>
<span class="text-white/60">ez-pp.farm</span>
</div>
</div>
</div>
{/if}
{:else if element.widget === 'telemetry'}
<div
class="flex h-full w-full items-center gap-2 rounded-full border border-white/10 bg-black/40 px-3 text-xs text-white/80 backdrop-blur-md"
>
<div class="flex items-center gap-1">
<ThemeIcon name="wifi" set={iconSet} class="size-3 text-emerald-400" />
<span class="text-[10px]">{$themeGlobalData?.server?.ping ?? 19}ms</span>
</div>
<span
class="rounded-full border border-white/5 bg-white/10 px-2 py-0.5 text-[9px] text-white/50"
>
{$themeGlobalData?.osu?.stream ?? 'stable'}
</span>
</div>
{:else if element.widget === 'header'}
{#if element.variant === 'minimal'}
<div class="flex h-full items-center">
<span
class="theme-heading text-2xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
</div>
{:else if element.variant === 'compact'}
<div class="flex h-full items-center gap-3">
<span
class="theme-heading text-2xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
<span
class="rounded-full border border-white/10 bg-black/40 px-2.5 py-0.5 text-xs font-medium text-white/80 shadow-sm backdrop-blur-md"
>
{timeGreeting},
<span class="font-semibold text-white">{$currentUser?.name ?? 'Guest'}</span>!
</span>
</div>
{:else}
<div class="flex h-full flex-col justify-center">
<span
class="theme-heading text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
{timeGreeting},
<span class="font-semibold text-white">{$currentUser?.name ?? 'Guest'}</span>!
</span>
</div>
{/if}
{:else if element.widget === 'avatar'}
{@const shapeClass =
element.variant === 'rounded'
? 'rounded-2xl'
: element.variant === 'square'
? 'rounded-md'
: 'rounded-full'}
{@const playerId = $currentUser?.id ?? $currentUserInfo?.info?.id ?? 0}
{@const playerName = $currentUser?.name ?? 'Player'}
{@const country = $currentUserInfo?.info?.country}
{@const level = $currentUserInfo?.stats?.['0']?.level}
<div
class="group/avatar relative flex h-full w-full items-center justify-center {interactive
? ''
: 'cursor-pointer'}"
role="button"
tabindex="0"
onclick={(e) => {
if (!interactive && playerId) {
e.stopPropagation();
void openURL(`https://ez-pp.farm/u/${playerId}`);
}
}}
onkeydown={(e) => {
if (!interactive && playerId && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
e.stopPropagation();
void openURL(`https://ez-pp.farm/u/${playerId}`);
}
}}
title={playerName}
>
<!-- Online Glow Ring -->
{#if element.showOnlineRing !== false}
<div
class="absolute inset-0 {shapeClass} ring-2 ring-primary/40 ring-offset-2 ring-offset-black/50 transition-all duration-300 group-hover/avatar:ring-primary/80"
></div>
{/if}
<!-- Avatar Image / Fallback -->
<Avatar.Root
class="h-full w-full {shapeClass} overflow-hidden border border-white/15 bg-theme-900 shadow-lg transition-transform duration-300 group-hover/avatar:scale-105"
>
<Avatar.Image
src="https://a.ez-pp.farm/{playerId}"
alt={playerName}
class="h-full w-full object-cover"
/>
<Avatar.Fallback
class="flex h-full w-full items-center justify-center bg-theme-900 text-primary"
>
<ThemeIcon
name="user"
set={iconSet}
size={Math.max(
16,
Math.round(Math.min(element.width, element.height) * 0.45)
)}
/>
</Avatar.Fallback>
</Avatar.Root>
<!-- Country Flag Emoji Badge -->
{#if element.showCountry !== false && country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center rounded-md border border-white/20 bg-black/90 px-1 py-0.5 text-[10px] leading-none shadow-md backdrop-blur-sm"
title={country.toUpperCase()}
>
{countryCodeToEmoji(country)}
</span>
{/if}
<!-- Level Badge -->
{#if element.showLevel && level !== undefined}
<span
class="absolute -top-1 -left-1 flex items-center justify-center rounded-md border border-primary/40 bg-black/90 px-1 py-0.5 text-[9px] font-bold leading-none text-primary shadow-md"
>
Lv.{level}
</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
+18
View File
@@ -26,6 +26,24 @@ export const themeElementLabel = (element: ThemeV2Element): string => {
if (element.type === 'countdown')
return element.target ? `Countdown · ${element.target}` : 'Countdown';
if (element.type === 'svelte') return 'Svelte component';
if (element.type === 'widget') {
switch (element.widget) {
case 'launch-button':
return 'Launch button widget';
case 'player-card':
return 'Player card widget';
case 'recent-activity':
return 'Recent activity widget';
case 'telemetry':
return 'Telemetry bar widget';
case 'header':
return 'Header widget';
case 'avatar':
return 'Player avatar widget';
default:
return 'Widget';
}
}
return element.src.split('/').pop() || element.type;
};
+918 -49
View File
File diff suppressed because it is too large. Load diff
+163 -3
View File
@@ -510,6 +510,33 @@ export type ThemeV2SvelteElement = ThemeV2ElementBase & {
source: string;
};
export type ThemeV2WidgetKind =
| 'player-card'
| 'recent-activity'
| 'launch-button'
| 'telemetry'
| 'header'
| 'avatar';
export type ThemeV2WidgetVariant =
| 'primary'
| 'full'
| 'compact'
| 'minimal'
| 'icon-only'
| 'circle'
| 'rounded'
| 'square';
export type ThemeV2WidgetElement = ThemeV2ElementBase & {
type: 'widget';
widget: ThemeV2WidgetKind;
variant?: ThemeV2WidgetVariant;
showCountry?: boolean;
showLevel?: boolean;
showOnlineRing?: boolean;
};
export type ThemeV2Element =
| ThemeV2TextElement
| ThemeV2DataElement
@@ -526,7 +553,20 @@ export type ThemeV2Element =
| ThemeV2ParticlesElement
| ThemeV2ClockElement
| ThemeV2CountdownElement
| ThemeV2SvelteElement;
| ThemeV2SvelteElement
| ThemeV2WidgetElement;
export type ThemeV2LayoutMode = 'dashboard' | 'classic' | 'custom';
export type ThemeV2Chrome = {
layoutMode?: ThemeV2LayoutMode;
header?: boolean;
pills?: boolean;
playerCard?: boolean;
recentActivity?: boolean;
dock?: boolean;
dockTelemetry?: boolean;
};
export type ThemeV2ScriptScreen = 'home' | 'themes' | 'settings' | 'login';
export type ThemeV2ScriptValue = string | number | boolean;
@@ -624,6 +664,8 @@ export type ThemeV2Definition = {
accentColor?: string;
/** Optional CSS appended after the launcher's own styles. */
css?: string;
/** Launcher page layout mode and visibility of chrome components. */
chrome?: ThemeV2Chrome;
};
export type ThemeV2Asset = {
@@ -912,6 +954,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
'clock',
'countdown',
'svelte',
'widget',
] as const,
'text'
);
@@ -932,7 +975,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
opacity: clamp(source.opacity, 1, 0, 1),
hidden: source.hidden === true,
locked: source.locked === true,
interactive: source.interactive === true,
interactive: source.interactive === true || type === 'button' || type === 'widget',
groupId: typeof source.groupId === 'string' ? source.groupId.slice(0, 64) : undefined,
filters: normalizeFilters(source.filters),
mask: normalizeMask(source.mask),
@@ -1190,6 +1233,41 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin
};
}
if (type === 'widget') {
const widget = oneOf(
source.widget,
['player-card', 'recent-activity', 'launch-button', 'telemetry', 'header', 'avatar'] as const,
'launch-button'
);
const variant = oneOf(
source.variant,
[
'primary',
'full',
'compact',
'minimal',
'icon-only',
'circle',
'rounded',
'square',
] as const,
widget === 'launch-button' ? 'primary' : widget === 'avatar' ? 'circle' : 'full'
);
const showCountry = typeof source.showCountry === 'boolean' ? source.showCountry : true;
const showLevel = typeof source.showLevel === 'boolean' ? source.showLevel : false;
const showOnlineRing =
typeof source.showOnlineRing === 'boolean' ? source.showOnlineRing : true;
return {
...base,
type,
widget,
variant,
showCountry,
showLevel,
showOnlineRing,
};
}
const src = text(source.src).slice(0, 500);
if (!src) return undefined;
return {
@@ -1518,7 +1596,15 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
}
if (type === 'button') {
return normalizeElement(
{ ...base, type, label: 'Open themes', width: 220, height: 52, y: 320 },
{
...base,
type,
label: 'Open themes',
action: { type: 'navigate', screen: 'themes' },
width: 220,
height: 52,
y: 320,
},
index
)! as ThemeV2ButtonElement;
}
@@ -1572,6 +1658,18 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
index
)! as ThemeV2SvelteElement;
}
if (type === 'widget') {
return normalizeElement(
{
...base,
type,
widget: 'launch-button',
width: 220,
height: 60,
},
index
)! as ThemeV2WidgetElement;
}
return normalizeElement(
{ ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 },
index
@@ -1581,6 +1679,58 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T
export const isThemeV2 = (apiVersion: unknown) =>
typeof apiVersion === 'string' && apiVersion.split('.')[0] === '2';
export const normalizeChrome = (raw: unknown, elements: ThemeV2Element[] = []): ThemeV2Chrome => {
const hasCustomElements = elements.length > 0;
if (!raw || typeof raw !== 'object') {
if (hasCustomElements) {
return {
layoutMode: 'classic',
header: true,
pills: false,
playerCard: true,
recentActivity: false,
dock: true,
dockTelemetry: true,
};
}
return {
layoutMode: 'dashboard',
header: true,
pills: true,
playerCard: true,
recentActivity: true,
dock: true,
dockTelemetry: true,
};
}
const source = raw as Record<string, unknown>;
const layoutMode = oneOf(
source.layoutMode,
['dashboard', 'classic', 'custom'] as const,
hasCustomElements ? 'classic' : 'dashboard'
);
return {
layoutMode,
header: source.header !== false,
pills: source.pills !== false,
playerCard: source.playerCard !== false,
recentActivity: source.recentActivity !== false,
dock: source.dock !== false,
dockTelemetry: source.dockTelemetry !== false,
};
};
export const themeHasLaunchButton = (
definition: Pick<ThemeV2Definition, 'elements' | 'chrome'>
): boolean => {
if (definition.chrome?.dock !== false) return true;
return definition.elements.some(
(el) =>
(el.type === 'widget' && el.widget === 'launch-button') ||
(el.type === 'button' && el.action.type === 'launch')
);
};
export const createDefaultThemeV2 = (): ThemeV2Definition => ({
name: 'Untitled theme',
version: '1.0.0',
@@ -1617,6 +1767,15 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({
behaviors: [],
accentColor: '#ff006e',
css: '',
chrome: {
layoutMode: 'dashboard',
header: true,
pills: true,
playerCard: true,
recentActivity: true,
dock: true,
dockTelemetry: true,
},
});
/** Coerce a theme.json payload into the supported v2 shape. */
@@ -1731,6 +1890,7 @@ export const normalizeThemeV2 = (raw: Partial<ThemeV2Definition>): ThemeV2Defini
variables: normalizeVariableDefaults(raw.variables),
accentColor: color(raw.accentColor, '#ff006e'),
css: typeof raw.css === 'string' ? raw.css : '',
chrome: normalizeChrome(raw.chrome, elements),
};
};
+26
View File
@@ -55,6 +55,32 @@ export const formatTimeReadable = (initialSeconds: number) => {
return result.trim();
};
export const formatRelativeTime = (dateOrTimestamp: Date | string | number | undefined): string => {
if (!dateOrTimestamp) return 'recently';
const time = new Date(dateOrTimestamp).getTime();
if (isNaN(time)) return 'recently';
const diff = Date.now() - time;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
};
export const countryCodeToEmoji = (code?: string): string => {
if (!code || code.length !== 2) return '🌐';
const codePoints = code
.toUpperCase()
.split('')
.map((c) => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
export const releaseStreamToReadable = (releaseStream: string) => {
if (releaseStream.replaceAll(' ', '').toLowerCase() === 'cuttingedge') return 'Cutting Edge';
return 'Stable';