Files
EZPPLauncher/src/screens/Launch.svelte
T

4128 lines
172 KiB
Svelte

<script lang="ts">
import DefaultThemePreview from '$assets/default_preview.png';
import Logo from '$assets/logo.png';
import { ezppfarm } from '$lib/api/ezpp';
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
import LogViewer from '$lib/components/logs/LogViewer.svelte';
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Avatar from '$lib/components/ui/avatar';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import DownloadButton from '$lib/components/ui/download-button/DownloadButton.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import Progress from '$lib/components/ui/progress/progress.svelte';
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
import * as Select from '$lib/components/ui/select';
import CommunityWorkshopView from '$lib/components/theme/CommunityWorkshopView.svelte';
import { config } from '$lib/config';
import { currentUserInfo } from '$lib/data';
import {
loadDiscordPresenceConfig,
pushIdlePresenceFromConfig,
pushPlayingPresenceFromConfig,
} from '$lib/discordPresenceConfig';
import { useDropZone } from '$lib/dropZone.svelte';
import {
getGamemodeInt,
getGamemodeName,
getModeAndTypeFromGamemode,
modeIntToStr,
typeIntToStr,
validModeTypeCombinationsSorted,
} from '$lib/gamemode';
import {
active_custom_theme,
beatmapSets,
currentSkin,
currentView,
custom_theme_container,
custom_theme_volume,
custom_themes,
discordPresence,
launcherStream,
launcherStreams,
launcherVersion,
launching,
newVersion,
openTabletDriverEnabled,
openTabletDriverPath,
osuBuild,
osuStream,
platform,
presenceLoading,
serverConnectionFails,
serverPing,
skinsCount,
theme_reload_token,
trackingEnabled,
} from '$lib/global';
import {
downloadEZPPLauncherUpdateFiles,
downloadUpdate,
encryptString,
exit,
getBeatmapSetsCount,
getEZPPLauncherUpdateFiles,
getReleaseStream,
getSkin,
getSkinsCount,
getVersion,
hasNet8,
hasOsuWinello,
hasWMCTRL,
installUpdate,
isOpenTabletDriverPathValid,
isOpenTabletDriverRunning,
isOsuCorrupted,
isOsuRunning,
isValidOsuFolder,
openLogsFolder,
pickOsuInstallation,
resolveOsuFolder,
runOsu,
runUpdater,
setConfigValues,
setUserConfigValues,
startOpenTabletDriver,
stopOpenTabletDriver,
} from '$lib/osuUtil';
import * as presence from '$lib/presence';
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
import { themeMediaSuspended } from '$lib/themeMedia';
import {
loadThemeScript,
setThemeScriptHandlers,
setThemeScriptScreen,
stopThemeScript,
themeScriptState,
triggerThemeScriptElement,
} from '$lib/themeScript';
import { isThemeTrusted, themeCodeKind, trustTheme } from '$lib/themeTrust';
import {
normalizeChrome,
themeAssetUrl,
themeV2ThumbnailStyle,
type ThemeV2ButtonAction,
type ThemeV2Chrome,
type ThemeV2Definition,
type ThemeV2ScriptScreen,
} from '$lib/themeV2';
import {
checkThemeFromFile,
deleteTheme,
downloadTheme,
importThemeFromFile,
loadTheme,
openThemesFolder,
reloadThemes,
restoreThemeBackup,
themesDirectory,
type Theme,
type ThemeBackup,
type ThemeInfo,
} from '$lib/themes';
import { EZPPActionStatus, type LaunchView } from '$lib/types';
import { currentUser } from '$lib/userAuthentication';
import {
cursorSmoothening,
customCursor,
gameOptimization,
loggingEnabled,
osuInstallationPath,
patch,
preferredMode,
preferredType,
reduceAnimations,
} from '$lib/userSettings';
import {
compareBuildNumbers,
fadeGlobalVolume,
formatBytes,
numberHumanReadable,
openURL,
pauseGlobalMedia,
releaseStreamToReadable,
resumeGlobalMedia,
setCurrentTimeGlobalMedia,
urlIsValidImage,
} from '$lib/utils';
import {
ChevronLeft,
ChevronRight,
Clock,
ExternalLink,
Globe,
Medal,
TrendingDown,
TrendingUp,
Trophy,
} from '@lucide/svelte';
import NumberFlow from '@number-flow/svelte';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { open } from '@tauri-apps/plugin-dialog';
import { animate } from 'animejs';
import { sileo } from 'sileo';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly, scale } from 'svelte/transition';
import { TextMorph } from 'torph/svelte';
let selectedView = $state<LaunchView>('HOME');
let pendingExternalUrl = $state<string | undefined>(undefined);
const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET);
const settingsTabs = [
{
id: 'gameplay',
label: 'Gameplay',
description: 'Patching behavior and the patcher release stream.',
icon: 'gameplay',
},
{
id: 'appearance',
label: 'Appearance',
description: 'Cursor, motion and animation preferences.',
icon: 'themes',
},
{
id: 'integrations',
label: 'Integrations',
description: 'Discord presence and anonymous usage tracking.',
icon: 'integrations',
},
{
id: 'installation',
label: 'Installation',
description: 'osu! location and platform-specific tools.',
icon: 'installation',
},
{
id: 'diagnostics',
label: 'Diagnostics',
description: 'Installation health, Wine, FFmpeg and dependencies.',
icon: 'diagnostics',
},
{
id: 'logging',
label: 'Logging',
description: 'Log capture, rotation and support bundles.',
icon: 'logs',
},
] as const;
type SettingsTab = (typeof settingsTabs)[number]['id'];
let activeSettingsTab = $state<SettingsTab>('gameplay');
const sidebarButtonClass = (view: LaunchView) =>
`flex size-12 items-center gap-2 rounded-[0.85rem] p-3 mt-3 ring-1 ring-inset ring-white/15 transition-colors ${
selectedView === view
? 'bg-primary text-primary-foreground'
: 'border-black/20 bg-black/20 text-theme-200 hover:bg-primary/20 hover:text-theme-50'
}`;
const settingsTabClass = (id: SettingsTab) =>
`flex h-8 items-center gap-1.5 whitespace-nowrap rounded-md px-2.5 text-[11px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${
activeSettingsTab === id
? 'bg-theme-800/80 text-theme-50'
: 'text-muted-foreground hover:bg-theme-900/60 hover:text-theme-100'
}`;
const activeSettings = $derived(
settingsTabs.find((tab) => tab.id === activeSettingsTab) ?? settingsTabs[0]
);
let progress = $state(-1);
let launchInfo = $state('');
let launchError = $state<Error | undefined>(undefined);
let ezppLogo: HTMLImageElement;
let askForTrackingPermission = $state(false);
let downloadingUpdate = $state(false);
let updateCheckFailed = $state(false);
let dragAndDrop = useDropZone({
onDrop: async (file) => {
const firstFile = file[0];
if (!firstFile.endsWith('.ezpplauncher-theme')) {
sileo.error({
title: 'Uhh...',
description: 'Dropped file is not a valid theme file.',
});
return;
}
const importResult = await checkThemeFromFile(firstFile);
if (!importResult.success || !importResult.themeInfo) {
sileo.error({
title: 'Hmmm...',
description: importResult.error || 'An unknown error occurred.',
});
return;
}
droppedTheme = {
filePath: firstFile,
themeInfo: importResult.themeInfo,
};
},
});
let droppedTheme = $state<
| {
filePath: string;
themeInfo: ThemeInfo;
}
| undefined
>(undefined);
let themeInstalling = $state(false);
let themeSearch = $state('');
let activeThemesTab = $state<'library' | 'workshop'>('library');
let themeSavedUnlisten: (() => void) | undefined;
let refreshingThemes = $state(false);
let reloadingActiveTheme = $state(false);
let revealedNsfw = $state<string[]>([]);
let trustDialogTheme = $state<Theme | undefined>(undefined);
let trustDialogEditor = $state(false);
const trustDialogCode = $derived(trustDialogTheme ? themeCodeKind(trustDialogTheme) : 'none');
const requestThemeTrust = (theme: Theme, forEditor = false) => {
trustDialogEditor = forEditor;
trustDialogTheme = theme;
};
const dismissThemeTrust = () => {
trustDialogTheme = undefined;
trustDialogEditor = false;
};
let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined);
let restoringTheme = $state(false);
let logViewerOpen = $state(false);
let presenceEditorOpen = $state(false);
const themeFilterOptions = [
{ id: 'all', label: 'All themes', icon: 'all-themes' },
{ id: 'updates', label: 'Updates', icon: 'updates' },
] as const;
type ThemeFilter = (typeof themeFilterOptions)[number]['id'];
let themeFilter = $state<ThemeFilter>('all');
const themeFilterClass = (id: ThemeFilter) =>
`flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[11px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${
themeFilter === id
? 'bg-theme-800/80 text-theme-50'
: 'text-muted-foreground hover:bg-theme-900/60 hover:text-theme-100'
}`;
const themeCounts = $derived({
all: $custom_themes.length,
updates: $custom_themes.filter((theme) => theme.updateAvailable).length,
});
const filteredThemes = $derived.by(() => {
const query = themeSearch.trim().toLowerCase();
return $custom_themes.filter((theme) => {
if (themeFilter === 'updates' && !theme.updateAvailable) return false;
if (!query) return true;
const apiPart = theme.name === 'Default' ? '' : theme.apiVersion;
return `${theme.name} ${theme.author} ${theme.description} ${apiPart}`
.toLowerCase()
.includes(query);
});
});
const themeButtonLabel = (theme: Theme, activeFolder: string | undefined) => {
if (activeFolder === theme.folder_name) return 'Theme in use';
if (theme.status === 'downloading') {
return `${theme.updateAvailable ? 'Updating' : 'Downloading'} Theme... (${Math.round(theme.progress * 100)}%)`;
}
if (theme.status === 'extracting') {
return `Extracting Theme... (${Math.round(theme.progress * 100)}%)`;
}
if (theme.status === 'deleting') return 'Uninstalling Theme...';
if (theme.status === 'installed') return 'Use Theme';
return 'Download Theme';
};
const openSetupWizard = async () => {
try {
const setupWizard = await import('./SetupWizard.svelte');
currentView.set(setupWizard.default);
} catch (error) {
console.error(error);
sileo.error({
title: 'Could not open the setup wizard',
description: error instanceof Error ? error.message : String(error),
});
}
};
const openThemeEditor = async (theme?: Theme) => {
// Opening the editor mounts the theme's layers, so an untrusted theme
// must pass the same approval gate as activating it.
if (theme && !isThemeTrusted(theme)) {
requestThemeTrust(theme, true);
return;
}
// Pause immediately instead of waiting for the native window event, so
// the launcher track cannot bleed into the editor while its window opens.
themeMediaSuspended.set(true);
try {
const themeFolder = theme
? await path.join(await themesDirectory(), theme.folder_name)
: null;
await invoke('open_theme_editor', { themeFolder });
} catch (error) {
themeMediaSuspended.set(false);
console.error(error);
sileo.error({
title: 'Could not open the theme editor',
description: error instanceof Error ? error.message : String(error),
});
}
};
const applyInstalledTheme = async (theme: Theme) => {
if (!$custom_theme_container) {
sileo.error({
title: 'Uhhm..',
description: 'Failed to apply theme.',
});
return;
}
loadTheme(theme, $custom_theme_container, $custom_theme_volume);
await config.value<string>('theme').set(theme.name);
};
const updateInstalledTheme = async (theme: Theme) => {
const wasActive = $active_custom_theme?.folder_name === theme.folder_name;
const updateResult = await downloadTheme(theme, true);
if (!updateResult.success) {
sileo.error({
title: 'Uhhm..',
description: updateResult.error || 'Failed to update theme.',
});
return;
}
if (wasActive) {
const updatedTheme = get(custom_themes).find((candidate) => candidate.name === theme.name);
if (updatedTheme) {
if (!isThemeTrusted(updatedTheme)) {
requestThemeTrust(updatedTheme);
} else {
await applyInstalledTheme(updatedTheme);
}
}
}
sileo.success({
title: 'Yaay!',
description: 'Theme updated successfully!',
});
};
const requestThemeApply = async (theme: Theme) => {
if (theme.status !== 'installed') {
const downloadThemeResult = await downloadTheme(theme);
if (!downloadThemeResult.success) {
if (downloadThemeResult.error && downloadThemeResult.error.length > 0) {
sileo.error({
title: 'Uhhm..',
description: downloadThemeResult.error,
});
}
return;
}
sileo.success({
title: 'Yaay!',
description: 'Theme installed successfully!',
});
return;
}
if (!isThemeTrusted(theme)) {
requestThemeTrust(theme);
return;
}
await applyInstalledTheme(theme);
};
const confirmThemeTrust = async () => {
const theme = trustDialogTheme;
if (!theme) return;
const openEditorAfterTrust = trustDialogEditor;
trustTheme(theme);
dismissThemeTrust();
if (openEditorAfterTrust) {
await openThemeEditor(theme);
return;
}
await applyInstalledTheme(theme);
};
const applySavedTheme = async (folderName: string) => {
// Saving an edit must not activate the theme. Only refresh the launcher's
// copy when the saved theme was already the active one.
const wasActive = $active_custom_theme?.folder_name === folderName;
await reloadThemes();
if (!wasActive) return;
const savedTheme = get(custom_themes).find((theme) => theme.folder_name === folderName);
if (!savedTheme) return;
const reloadVersion = Date.now();
const themeToApply = savedTheme.definition
? {
...savedTheme,
assets: savedTheme.assets
? `${savedTheme.assets}${savedTheme.assets.includes('?') ? '&' : '?'}v=${reloadVersion}`
: savedTheme.assets,
}
: savedTheme;
await applyInstalledTheme(themeToApply);
theme_reload_token.update((value) => value + 1);
};
const reloadActiveTheme = async () => {
if (reloadingActiveTheme) return;
const active = $active_custom_theme;
if (!active) return;
reloadingActiveTheme = true;
try {
await reloadThemes();
// The user may have switched themes while the theme list was reloading.
if ($active_custom_theme?.folder_name !== active.folder_name) return;
const refreshed = get(custom_themes).find(
(theme) => theme.folder_name === active.folder_name
);
if (!refreshed) {
sileo.error({
title: 'Theme not found',
description: `"${active.name}" is no longer installed.`,
});
return;
}
if (!isThemeTrusted(refreshed)) {
requestThemeTrust(refreshed);
return;
}
// Re-request v2 theme assets under a fresh URL so the WebView cannot
// keep serving a file that changed on disk. API 1.0 bundles use their
// own container and must not receive a query string in the assets path.
const reloadVersion = Date.now();
const themeToApply = refreshed.definition
? {
...refreshed,
assets: refreshed.assets
? `${refreshed.assets}${refreshed.assets.includes('?') ? '&' : '?'}v=${reloadVersion}`
: refreshed.assets,
}
: refreshed;
await applyInstalledTheme(themeToApply);
theme_reload_token.update((value) => value + 1);
sileo.success({
title: 'Theme reloaded',
description: `"${refreshed.name}" was reloaded from disk.`,
});
} catch (error) {
console.error(error);
sileo.error({
title: 'Could not reload the theme',
description: error instanceof Error ? error.message : String(error),
});
} finally {
reloadingActiveTheme = false;
}
};
const requestThemeRestore = (theme: Theme, backup: ThemeBackup) => {
restoreCandidate = { theme, backup };
};
const confirmThemeRestore = async () => {
if (!restoreCandidate || restoringTheme) return;
const { theme, backup } = restoreCandidate;
const wasActive = $active_custom_theme?.folder_name === theme.folder_name;
restoringTheme = true;
try {
const themeFolder = await path.join(await themesDirectory(), theme.folder_name);
await restoreThemeBackup(themeFolder, backup.path);
await reloadThemes();
const restoredTheme = get(custom_themes).find((candidate) => candidate.name === theme.name);
if (wasActive && restoredTheme) {
if (!isThemeTrusted(restoredTheme)) {
requestThemeTrust(restoredTheme);
} else {
await applyInstalledTheme(restoredTheme);
}
}
sileo.success({
title: 'Theme restored',
description: `"${theme.name}" was rolled back to the saved version.`,
});
} catch (error) {
console.error(error);
sileo.error({
title: 'Restore failed',
description: error instanceof Error ? error.message : String(error),
});
} finally {
restoringTheme = false;
restoreCandidate = undefined;
}
};
const toggleNsfwPreview = (name: string) => {
revealedNsfw = revealedNsfw.includes(name)
? revealedNsfw.filter((candidate) => candidate !== name)
: [...revealedNsfw, name];
};
const refreshThemes = async () => {
refreshingThemes = true;
try {
await reloadThemes();
} catch (error) {
console.error(error);
sileo.error({ title: 'Could not refresh themes' });
} finally {
refreshingThemes = false;
}
};
const themeBackgroundVideo = (theme: Theme) =>
theme.definition?.background.type === 'video'
? themeAssetUrl(theme.assets, theme.definition.background.src)
: '';
let downloadingEZPPFiles = $state(false);
let cleanup = $state(false);
let customGamemode = $state<number | undefined>(undefined);
let selectedGamemode = $derived(
customGamemode ?? getGamemodeInt(modeIntToStr($preferredMode), typeIntToStr($preferredType))
);
let selectedMode = $derived(getModeAndTypeFromGamemode(selectedGamemode).mode);
let selectedType = $derived(getModeAndTypeFromGamemode(selectedGamemode).type);
const updateGamemode = (newGamemode: string) => {
customGamemode = Number(newGamemode);
};
const currentStats = $derived($currentUserInfo?.stats?.[selectedGamemode]);
let recentActivityIndex = $state(0);
const userEvents = $derived($currentUserInfo?.events ?? []);
const safeEventIndex = $derived(
userEvents.length === 0 ? 0 : Math.min(recentActivityIndex, userEvents.length - 1)
);
const activeEvent = $derived(userEvents[safeEventIndex]);
const timeGreeting = $derived.by(() => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
});
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);
};
const formatRelativeTime = (dateOrTimestamp: Date | string | number | undefined): string => {
if (!dateOrTimestamp) return '';
const date = typeof dateOrTimestamp === 'object' ? dateOrTimestamp : new Date(dateOrTimestamp);
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffSeconds = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1000));
if (diffSeconds < 60) return 'just now';
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
};
const gradeBadgeStyle = (
grade?: string
): { bg: string; text: string; glow: string; label: string } => {
const g = grade?.toUpperCase() ?? '';
switch (g) {
case 'XH':
return {
bg: 'bg-cyan-500/20 border-cyan-400/50',
text: 'text-cyan-300',
glow: 'shadow-[0_0_12px_rgba(34,211,238,0.35)]',
label: 'SS',
};
case 'X':
return {
bg: 'bg-amber-400/20 border-amber-300/50',
text: 'text-amber-300',
glow: 'shadow-[0_0_12px_rgba(251,191,36,0.35)]',
label: 'SS',
};
case 'SH':
return {
bg: 'bg-slate-300/20 border-slate-200/50',
text: 'text-slate-100',
glow: 'shadow-[0_0_12px_rgba(241,245,249,0.25)]',
label: 'S',
};
case 'S':
return {
bg: 'bg-amber-500/20 border-amber-400/50',
text: 'text-amber-400',
glow: 'shadow-[0_0_12px_rgba(245,158,11,0.35)]',
label: 'S',
};
case 'A':
return {
bg: 'bg-emerald-500/20 border-emerald-400/50',
text: 'text-emerald-400',
glow: 'shadow-[0_0_12px_rgba(52,211,153,0.35)]',
label: 'A',
};
case 'B':
return {
bg: 'bg-blue-500/20 border-blue-400/50',
text: 'text-blue-400',
glow: 'shadow-[0_0_12px_rgba(96,165,250,0.35)]',
label: 'B',
};
case 'C':
return {
bg: 'bg-purple-500/20 border-purple-400/50',
text: 'text-purple-400',
glow: 'shadow-[0_0_12px_rgba(192,132,252,0.35)]',
label: 'C',
};
case 'D':
case 'F':
return {
bg: 'bg-rose-500/20 border-rose-400/50',
text: 'text-rose-400',
glow: 'shadow-[0_0_12px_rgba(251,113,133,0.35)]',
label: g || 'D',
};
default:
return {
bg: 'bg-white/10 border-white/20',
text: 'text-white/80',
glow: '',
label: g || '-',
};
}
};
const openDiscordPresenceEditor = async () => {
try {
await invoke('open_presence_editor');
} catch {
presenceEditorOpen = true;
}
};
const activeChrome = $derived.by<ThemeV2Chrome>(() => {
const def = $active_custom_theme?.definition;
if (!def) {
return {
layoutMode: 'dashboard',
header: true,
pills: true,
playerCard: true,
recentActivity: true,
dock: true,
dockTelemetry: true,
};
}
return normalizeChrome(def.chrome, def.elements);
});
const hasThemeLaunchButton = $derived.by<boolean>(() => {
const def = $active_custom_theme?.definition;
if (!def) return false;
return def.elements.some(
(el) =>
(el.type === 'widget' && el.widget === 'launch-button') ||
(el.type === 'button' && el.action.type === 'launch')
);
});
const browse_osu_installation = async () => {
const selectedPath = await pickOsuInstallation($platform);
if (typeof selectedPath === 'string') {
if (selectedPath === $osuInstallationPath) {
return;
}
const resolvedPath = await resolveOsuFolder(selectedPath);
if (!resolvedPath) {
sileo.error({
title: 'Hmm...',
description:
$platform === 'macos'
? 'The selected item does not contain a valid osu! installation. Pick your osu!.app wrapper.'
: 'The selected folder is not a valid osu! installation folder. Please select the correct folder.',
});
return;
}
osuInstallationPath.set(resolvedPath);
await config.value<string>('osu_installation_path').set(resolvedPath);
sileo.success({
title: 'Yay!',
description: 'osu! installation path set successfully.',
});
const beatmapSetCount = await getBeatmapSetsCount(resolvedPath);
if (beatmapSetCount) beatmapSets.set(beatmapSetCount);
const skins_count = await getSkinsCount(resolvedPath);
if (skins_count) {
/* skins.set(skins_list); */
skinsCount.set(skins_count);
}
const skin: string = await getSkin(resolvedPath);
currentSkin.set(skin);
const osuReleaseStream = await getReleaseStream(resolvedPath);
osuStream.set(osuReleaseStream);
const osuVersion = await getVersion(resolvedPath);
osuBuild.set(osuVersion);
}
};
const revealLauncherWindow = async () => {
try {
await invoke('reveal_window');
} catch {
await getCurrentWindow().show();
await getCurrentWindow().unminimize();
await getCurrentWindow().setFocus();
}
};
const launch = async (skipUpdateCheck = false) => {
if ($launching) return;
launchInfo = 'Checking if osu! is already running...';
launching.set(true);
if ($trackingEnabled) umami.track('app_launch_osu');
const osuRunning = await isOsuRunning();
if (osuRunning) {
sileo.error({
title: 'Hold on a second!',
description:
'osu! is currently running, please exit osu! before launching via EZPPLauncher!',
});
launching.set(false);
return;
}
launchInfo = 'Checking osu! installation...';
if (!$osuBuild) {
sileo.error({
title: 'Hmmm...',
description: 'There was an issue detecting your installed osu! version',
});
launching.set(false);
return;
}
const osuPath = $osuInstallationPath;
launchInfo = 'Validating osu! installation...';
const validFolder = await isValidOsuFolder(osuPath);
if (!validFolder) {
sileo.error({
title: 'Hmmm...',
description: 'Your selected osu! installation folder is not valid.',
});
launching.set(false);
return;
}
if ($platform === 'linux') {
if (!(await hasWMCTRL())) {
sileo.error({
title: 'Hmmm...',
description: 'wmctrl seems to be missing, please install via AUR.',
});
launching.set(false);
return;
}
if (!(await hasOsuWinello())) {
sileo.error({
title: 'Hmmm...',
description: 'osu-winello seems to be missing, please install it.',
});
launching.set(false);
return;
}
}
if (!skipUpdateCheck) {
try {
launchInfo = 'Looking for file updates...';
let launchStream = $launcherStream;
if ($platform !== 'windows') launchStream = 'stable'; //fallback to stable for non windows systems
const updateResult = await getEZPPLauncherUpdateFiles(osuPath, launchStream);
if (updateResult) {
if (updateResult.filesToDownload.length > 0) {
downloadingEZPPFiles = true;
launchInfo = 'Found file updates!';
await new Promise((res) => setTimeout(res, 1000));
await downloadEZPPLauncherUpdateFiles(
osuPath,
updateResult.filesToDownload,
updateResult.updateFiles,
(file) => {
progress = file.progress;
launchInfo = `${file.fileName}(${formatBytes(
file.downloaded
)}/${formatBytes(file.size)})...`;
}
);
progress = -1;
downloadingEZPPFiles = false;
} else {
launchInfo = 'EZPPLauncher Files are up to date!';
await new Promise((res) => setTimeout(res, 1500));
}
}
} catch (err) {
launchError = err as Error;
launching.set(false);
return;
}
}
try {
if (!skipUpdateCheck) {
const streamInfo = await ezppfarm.latestBuildVersion('stable40');
if (!streamInfo) {
sileo.error({
title: 'Hmmm...',
description: 'Failed to check for updates, maybe osu! is down?',
});
launching.set(false);
updateCheckFailed = true;
return;
}
const releaseStream = await getReleaseStream(osuPath);
if (releaseStream === undefined) {
sileo.error({
title: 'Hmmm...',
description: 'Failed to get osu! release stream.',
});
launching.set(false);
return;
}
// only stable osu! release streams are supported for now
if (!releaseStream.toLowerCase().includes('stable')) {
sileo.error({
title: 'Hmmm...',
description: 'You are not on the stable release stream, please switch to it.',
});
launching.set(false);
return;
}
const osuCorrupted = await isOsuCorrupted(osuPath);
let forceUpdate =
(releaseStream && releaseStream.toLowerCase() !== 'stable40') || osuCorrupted;
const versions = compareBuildNumbers($osuBuild, streamInfo);
if (versions > 0 || forceUpdate) {
launchInfo = 'Update found!';
await new Promise((res) => setTimeout(res, 1500));
launchInfo = 'Running osu! updater...';
await setUserConfigValues(osuPath, [
{
key: 'LastVersion',
value: `b${streamInfo}`,
},
]);
await setConfigValues(osuPath, [
{
key: '_ReleaseStream',
value: 'Stable40',
},
]);
osuStream.set('Stable40');
osuBuild.set(`b${streamInfo}`);
await runUpdater(osuPath);
launchInfo = 'osu! is now up to date!';
if (forceUpdate)
await setConfigValues(osuPath, [
{
key: '_UpdateFailCount',
value: '0',
},
]);
} else {
launchInfo = 'You are up to date!';
}
}
if ($currentUser) {
const [username, password] = await Promise.all([
config.value<string>('username').get(''),
config.value<string>('password').get(''),
]);
if (username.length > 0 && password.length > 0) {
await setUserConfigValues(osuPath, [
{
key: 'Username',
value: username,
},
{
key: 'Password',
value:
$platform === 'windows'
? await encryptString(password, 'cu24180ncjeiu0ci1nwui')
: password,
},
{
key: 'SaveUsername',
value: '1',
},
{
key: 'SavePassword',
value: '1',
},
{
key: 'CredentialEndpoint',
value: 'ez-pp.farm',
},
]);
}
} else {
await setUserConfigValues(osuPath, [
{
key: 'Username',
value: '',
},
{
key: 'Password',
value: '',
},
{
key: 'SaveUsername',
value: '1',
},
{
key: 'SavePassword',
value: '0',
},
{
key: 'CredentialEndpoint',
value: 'ez-pp.farm',
},
]);
}
let otd_run = false;
if ($openTabletDriverEnabled && $openTabletDriverPath.length > 0) {
const otdRunning = await isOpenTabletDriverRunning();
if (!otdRunning) {
launchInfo = 'Starting OpenTabletDriver...';
const validDaemon = await isOpenTabletDriverPathValid($openTabletDriverPath);
if (!validDaemon) {
sileo.error({
title: 'Hmmm...',
description: 'Failed to start OpenTabletDriver, executable not found.',
});
launching.set(false);
return;
}
try {
await startOpenTabletDriver($openTabletDriverPath);
otd_run = true;
} catch (err) {
console.log('Failed to start OpenTabletDriver:', err);
sileo.error({
title: 'Hmmm...',
description: 'Failed to start OpenTabletDriver.',
});
launching.set(false);
return;
}
}
}
await new Promise((res) => setTimeout(res, 1500));
launchInfo = 'Launching osu!...';
await fadeGlobalVolume($custom_theme_volume, 0, 800);
pauseGlobalMedia();
await new Promise((res) => setTimeout(res, 200));
await getCurrentWindow().hide();
let presenceUpdater: number | undefined = undefined;
const isPresenceConnected = await presence.isConnected();
if ($discordPresence && isPresenceConnected) {
let osuDetected = false;
presenceUpdater = window.setInterval(async () => {
if (!osuDetected) {
const osuRunning = await isOsuRunning();
if (osuRunning) osuDetected = true;
return;
}
if ($currentUser) {
const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats');
const userStatus = await ezppfarm.getUserStatus($currentUser.id);
if (userStatus?.player_status.online) {
let actionName = 'Idle...';
const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode);
const gamemodeName = getGamemodeName(
modeIntToStr(gamemode.mode),
typeIntToStr(gamemode.type)
);
switch (userStatus.player_status.status.action) {
case EZPPActionStatus.AFK:
actionName = 'AFK...';
break;
case EZPPActionStatus.PLAYING:
actionName = 'Playing...';
break;
case EZPPActionStatus.EDITING:
actionName = 'Editing...';
break;
case EZPPActionStatus.MODDING:
actionName = 'Modding...';
break;
case EZPPActionStatus.MULTIPLAYER_SELECT:
actionName = 'Multiplayer: Selecting a Beatmap...';
break;
case EZPPActionStatus.WATCHING:
actionName = 'Watching...';
break;
case EZPPActionStatus.TESTING:
actionName = 'Testing...';
break;
case EZPPActionStatus.SUBMITTING:
actionName = 'Submitting...';
break;
case EZPPActionStatus.MULTIPLAYER_IDLE:
actionName = 'Multiplayer: Idle...';
break;
case EZPPActionStatus.MULTIPLAYER_PLAYING:
actionName = 'Multiplayer: Playing...';
break;
case EZPPActionStatus.DIRECT:
actionName = 'Browsing osu!direct...';
break;
}
let beatmapCoverImage: string | null = null;
const beatmap = userStatus.player_status.status.beatmap;
let beatmapString =
userStatus.player_status.status.info_text.length > 0
? userStatus.player_status.status.info_text
: ' ';
if (beatmap !== null) {
beatmapString = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}]`;
const coverCandidate = `https://assets.ppy.sh/beatmaps/${beatmap.set_id}/covers/[email protected]`;
const isValidImage = await urlIsValidImage(coverCandidate);
if (isValidImage) beatmapCoverImage = coverCandidate;
}
try {
const currentModeStats =
userStats?.player.stats[userStatus.player_status.status.mode];
await pushPlayingPresenceFromConfig({
user: $currentUser,
mode: gamemodeName,
action: actionName,
beatmap: beatmapString,
artist: beatmap?.artist,
title: beatmap?.title,
diff: beatmap?.version,
version: $launcherVersion || undefined,
beatmapCoverUrl: beatmapCoverImage,
beatmapUrl: beatmap
? `https://ez-pp.farm/beatmapsets/${beatmap.set_id}/${beatmap.id}`
: null,
rank: currentModeStats?.rank,
pp: currentModeStats?.pp,
accuracy: currentModeStats ? `${currentModeStats.acc.toFixed(2)}%` : undefined,
});
} catch (err) {
console.log(err);
}
}
}
}, 1000 * 2);
}
await runOsu(osuPath, $patch, $gameOptimization);
if ($trackingEnabled) umami.track('app_exit_osu');
cleanup = true;
launchInfo = 'Cleaning up...';
if (otd_run) await stopOpenTabletDriver();
await revealLauncherWindow();
await new Promise((res) => setTimeout(res, 100));
setCurrentTimeGlobalMedia(0);
resumeGlobalMedia();
fadeGlobalVolume(0, $custom_theme_volume, 1500);
if (presenceUpdater) {
window.clearInterval(presenceUpdater);
try {
await pushIdlePresenceFromConfig({
user: $currentUser,
version: $launcherVersion || undefined,
});
} catch (err) {
console.log(err);
}
}
await new Promise((res) => setTimeout(res, 1000));
/* await replaceUIFiles(osuPath, true); */
const osuReleaseStream = await getReleaseStream(osuPath);
osuStream.set(osuReleaseStream);
const osuVersion = await getVersion(osuPath);
osuBuild.set(osuVersion);
const beatmapSetCount = await getBeatmapSetsCount(osuPath);
if (beatmapSetCount) beatmapSets.set(beatmapSetCount);
const skins_count = await getSkinsCount(osuPath);
if (skins_count) {
/* skins.set(skinsList); */
skinsCount.set(skins_count);
}
const skin = await getSkin(osuPath);
currentSkin.set(skin);
if ($currentUser) {
const userInfo = await ezppfarm.getUserInfo($currentUser.id);
if (userInfo) currentUserInfo.set(userInfo.player);
}
launching.set(false);
cleanup = false;
} catch (err) {
cleanup = false;
const error = err as Error;
await revealLauncherWindow();
resumeGlobalMedia();
fadeGlobalVolume(0, $custom_theme_volume, 1000);
if (error.name === 'AbortError') {
sileo.error({
title: 'Hmmm...',
description: 'Failed to launch.',
});
launching.set(false);
launchError = {
name: error.name,
message: 'Network request connection timed out.',
};
} else {
launchError = error;
sileo.error({
title: 'Hmmm...',
description: 'Failed to launch.',
});
launching.set(false);
if ($trackingEnabled) umami.track('app_launch_fail', { error: err });
}
}
};
setThemeScriptHandlers({
navigate: (screen) => {
selectedView = screen.toUpperCase() as LaunchView;
},
launch: () => {
void launch();
},
openUrl: (url) => {
if (pendingExternalUrl) return;
try {
const parsed = new URL(url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
pendingExternalUrl = parsed.toString();
}
} catch {
// The rule normalizer already drops malformed and non-web URLs.
}
},
});
const handleThemeButtonAction = (action: ThemeV2ButtonAction) => {
if (action.type === 'navigate') {
selectedView = action.screen.toUpperCase() as LaunchView;
return;
}
if (action.type === 'launch') {
void launch();
return;
}
if (pendingExternalUrl) return;
try {
const parsed = new URL(action.url);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
pendingExternalUrl = parsed.toString();
}
} catch {
// Theme actions are normalized to http(s) when the theme loads.
}
};
let scriptDefinition: ThemeV2Definition | undefined;
$effect(() => {
const next = $active_custom_theme?.definition;
if (next === scriptDefinition) return;
scriptDefinition = next;
pendingExternalUrl = undefined;
loadThemeScript(next);
});
$effect(() => {
setThemeScriptScreen(selectedView.toLowerCase() as ThemeV2ScriptScreen);
});
async function confirmExternalUrl() {
const url = pendingExternalUrl;
pendingExternalUrl = undefined;
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
await openURL(parsed.toString());
} catch (error) {
console.error(error);
sileo.error({ title: 'Could not open the website' });
}
}
let username = $state('');
let password = $state('');
let loginIsLoading = $state(false);
const performLogin = async () => {
loginIsLoading = true;
try {
const loginResult = await ezppfarm.login(username, password);
if (loginResult && loginResult.user) {
sileo.success({
title: 'Login successful!',
description: `Welcome back, ${loginResult.user.name}!`,
});
await Promise.all([
config.value<string>('username').set(username, { encrypt: true }),
config.value<string>('password').set(password, { encrypt: true }),
]);
currentUser.set(loginResult.user);
selectedView = 'HOME';
} else {
sileo.error({
title: 'Login failed!',
description: 'Please check your username and password.',
});
loginIsLoading = false;
}
} catch (err) {
console.log(err);
sileo.error({
title: 'Login failed!',
description: 'There was an issue connecting to the server. Please try again later.',
});
loginIsLoading = false;
}
if ($currentUser) {
const userInfo = await ezppfarm.getUserInfo($currentUser.id);
if (userInfo) {
currentUserInfo.set(userInfo.player);
preferredMode.set(userInfo.player.info.preferred_mode);
preferredType.set(userInfo.player.info.preferred_type);
}
}
};
let animateInterval: number | undefined;
const doBPMAnimation = () => {
if (animateInterval) return;
animateInterval = window.setInterval(async () => {
animate(ezppLogo, {
scale: 1.1,
duration: 900,
ease: (t: number) => Math.pow(2, -5 * t) * Math.sin((t - 0.075) * 20.94) + 1 - 0.0005 * t,
onComplete: () => {},
});
await new Promise((resolve) => setTimeout(resolve, 200));
animate(ezppLogo, {
scale: 1,
duration: 900,
ease: (t: number) => (t - 1) ** 7 + 1,
onComplete: () => {},
});
}, 450);
};
const setupThemeImport = async () => {
const initialUrls = await invoke<string[]>('opened_urls');
if (initialUrls.length > 0 && !droppedTheme) {
const firstFile = initialUrls[0];
if (!firstFile.endsWith('.ezpplauncher-theme')) {
sileo.error({
title: 'Uhh...',
description: 'Dropped file is not a valid theme file.',
});
return;
}
const importResult = await checkThemeFromFile(firstFile);
if (!importResult.success || !importResult.themeInfo) {
sileo.error({
title: 'Hmmm...',
description: importResult.error || 'An unknown error occurred.',
});
return;
}
droppedTheme = {
filePath: firstFile,
themeInfo: importResult.themeInfo,
};
}
await listen<string[]>('opened', async (event) => {
if (droppedTheme) return;
const files = event.payload;
if (files.length > 0) {
const firstFile = files[0];
if (!firstFile.endsWith('.ezpplauncher-theme')) {
sileo.error({
title: 'Uhh...',
description: 'Dropped file is not a valid theme file.',
});
return;
}
const importResult = await checkThemeFromFile(firstFile);
if (!importResult.success || !importResult.themeInfo) {
sileo.error({
title: 'Hmmm...',
description: importResult.error || 'An unknown error occurred.',
});
return;
}
droppedTheme = {
filePath: firstFile,
themeInfo: importResult.themeInfo,
};
}
});
};
const browse_otd = async () => {
const selectedPath = await open({
directory: false,
multiple: false,
title: 'Select OpenTabletDriver.Daemon Executable',
defaultPath: $openTabletDriverPath ?? undefined,
canCreateDirectories: false,
filters:
$platform === 'windows'
? [
{
name: 'OpenTabletDriver.Daemon.exe',
extensions: ['exe'],
},
]
: undefined,
});
if (typeof selectedPath === 'string') {
const fileName = selectedPath.split(/[\\/]/).pop();
if ($platform === 'windows' && fileName?.toLowerCase() !== 'opentabletdriver.daemon.exe') {
sileo.error({
title: 'Wrong executable',
description: 'Please select OpenTabletDriver.Daemon.exe',
});
return;
}
openTabletDriverPath.set(selectedPath);
await config.value<string>('otd_path').set(selectedPath);
sileo.success({
title: 'Yay!',
description: 'OpenTabletDriver set successfully.',
});
}
};
onMount(() => {
(async () => {
const trackingConsent = config.value<boolean>('tracking_consent');
if (await trackingConsent.exists()) {
trackingEnabled.set(await trackingConsent.get(false));
} else {
askForTrackingPermission = true;
}
})();
loadDiscordPresenceConfig().then((cfg) => {
if ($discordPresence) {
pushIdlePresenceFromConfig(
{
user: $currentUser,
version: $launcherVersion || undefined,
},
cfg
).catch(() => {});
}
});
animate(ezppLogo, {
opacity: [0, 1],
scale: [0.95, 1],
duration: 900,
ease: (t: number) => (t - 1) ** 7 + 1,
onComplete: doBPMAnimation,
});
setupThemeImport();
listen<string>('theme-saved', async (event) => {
try {
await applySavedTheme(event.payload);
} catch (error) {
console.error(error);
sileo.error({ title: 'Could not apply the saved theme' });
}
}).then((unlisten) => (themeSavedUnlisten = unlisten));
return () => {
window.clearInterval(animateInterval);
themeSavedUnlisten?.();
stopThemeScript();
};
});
</script>
<AlertDialog.Root open={launchError !== undefined}>
<AlertDialog.Content class="bg-theme-950 border-theme-800 p-0 max-w-[90vw]">
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<img class="h-20 w-20" src={Logo} alt="logo" />
<span class="font-semibold text-xl">Error on Launch!</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3 overflow-hidden"
>
{#if launchError}
<pre class="text-wrap text-start overflow-auto w-full">{JSON.stringify(
launchError,
Object.getOwnPropertyNames(launchError),
2
)}</pre>
{:else}
Unexpected error
{/if}
</div>
<div class="flex items-center justify-center mb-3">
<Button
onclick={async () => {
launchError = undefined;
}}>Close</Button
>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={$newVersion !== undefined}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<img class="h-20 w-20" src={Logo} alt="logo" />
<span class="font-semibold text-xl">Update available!</span>
</div>
<div
class="grid grid-cols-3 items-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
<div class="flex flex-col items-center justify-center">
<span class="text-sm text-muted-foreground">Current Version</span>
<span>{$launcherVersion}</span>
</div>
<div class="flex items-center justify-center">
<ThemeIcon name="arrow-right" set={iconSet} />
</div>
<div class="flex flex-col items-center justify-center">
<span class="text-sm text-muted-foreground">New Version</span>
<span class="text-green-400">{$newVersion?.tag_name}</span>
</div>
</div>
<div class="flex items-center justify-center mb-3">
{#if $platform === 'windows'}
{#if downloadingUpdate}
<div class="flex flex-col items-center justify-center gap-2 p-3 rounded-lg w-full">
<Progress indeterminate={progress === -1} value={progress} />
<span class="text-muted-foreground text-sm mt-4">{launchInfo}</span>
</div>
{:else}
<Button
onclick={async () => {
const updateFile = $newVersion?.assets.find((asset) => asset.name.endsWith('.exe'));
if (!updateFile) {
sileo.error({
title: 'Hmmm...',
description: 'No update file found.',
});
$newVersion = undefined;
return;
}
downloadingUpdate = true;
launchInfo = 'Downloading Update...';
await downloadUpdate(updateFile.browser_download_url, (file) => {
progress = file.progress;
launchInfo = `Downloading Update (${formatBytes(file.downloaded)}/${formatBytes(file.size)})...`;
});
progress = -1;
launchInfo = 'Update downloaded, installing...';
await installUpdate();
}}>Install Update now</Button
>
{/if}
{:else}
<Button
onclick={async () => {
if ($newVersion) {
await openURL($newVersion.html_url);
await exit();
}
}}>Update now</Button
>
{/if}
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={$newVersion === undefined && askForTrackingPermission}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<img class="h-20 w-20" src={Logo} alt="logo" />
<span class="font-semibold text-xl">App Tracking Consent</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
<p class="mb-4">
We value your privacy. To enhance your experience and improve our services, we would like to
collect anonymous usage data. This data helps us understand how the application is used and
identify areas for improvement.
</p>
<p class="mb-4">
No personal information is collected, and all data is anonymized. You can choose to enable
or disable this tracking at any time in the application settings.
</p>
<p>
Do you consent to the collection of anonymous usage data to help us improve the application?
</p>
</div>
<div class="flex items-center justify-center mb-3 gap-4 mt-4">
<Button
onclick={async () => {
trackingEnabled.set(true);
await config.value<boolean>('tracking_consent').set(true);
askForTrackingPermission = false;
}}>Yes, I consent</Button
>
<Button
variant="outline"
onclick={async () => {
trackingEnabled.set(false);
await config.value<boolean>('tracking_consent').set(false);
askForTrackingPermission = false;
}}>No, I do not consent</Button
>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={updateCheckFailed}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-2xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<ThemeIcon name="import" set={iconSet} size={40} />
<span class="font-semibold text-xl">Update check failed</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
<p class="mb-4">Failed to check for updates.</p>
<p>Do you want to launch anyway?</p>
</div>
<div class="flex items-center justify-center mb-3 gap-4 mt-4">
<Button
class="min-w-28"
onclick={async () => {
await launch(true);
updateCheckFailed = false;
}}
>
Launch anyways
</Button>
<Button
variant="outline"
onclick={async () => {
updateCheckFailed = false;
}}
>
Abort
</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={$newVersion === undefined && !!droppedTheme && !themeInstalling}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-2xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center border-b border-theme-800 bg-black/40 rounded-t-lg p-3"
>
<ThemeIcon name="import" set={iconSet} size={40} />
<span class="font-semibold text-xl">Import Theme</span>
</div>
<div
class="flex flex-col items-center text-sm text-center bg-theme-900 border border-theme-800 rounded-lg mx-3 p-3"
>
<p class="mb-4">
You are about to import the theme <strong>"{droppedTheme?.themeInfo.name}"</strong> by
<strong>{droppedTheme?.themeInfo.author}</strong>.
</p>
<div
class="mb-4 text-red-400 bg-red-800/20 border border-red-700/20 rounded-lg p-2 flex flex-col items-center justify-center"
>
<div class="text-base font-semibold">Warning</div>
<div>Themes from external sources may contain malicious code or assets.</div>
<div>Only import themes from creators you trust.</div>
</div>
<p>Are you sure you want to proceed with the installation?</p>
</div>
<div class="flex items-center justify-center mb-3 gap-4 mt-4">
<Button
class="min-w-28"
onclick={async () => {
if (!droppedTheme) {
sileo.error({
title: 'Hmmm...',
description: 'Failed to install theme.',
});
return;
}
themeInstalling = true;
try {
if ($active_custom_theme && $active_custom_theme.name === droppedTheme.themeInfo.name) {
const defaultTheme = $custom_themes.find((t) => t.name === 'Default');
if (defaultTheme) {
loadTheme(defaultTheme, $custom_theme_container!, $custom_theme_volume);
await config.value<string>('theme').set(defaultTheme.name);
}
}
await sileo.promise(
importThemeFromFile(droppedTheme.themeInfo.name, droppedTheme.filePath),
{
loading: {
title: 'Importing theme...',
},
error() {
return {
title: 'Hmmm...',
description: 'An unknown error occurred while importing your theme.',
};
},
success(data) {
return {
title: data.success ? 'Yay!' : 'Hmmm...',
description: data.success
? 'Theme imported successfully'
: data.error || 'An unknown error occurred while importing your theme.',
type: data.success ? 'success' : 'error',
};
},
}
);
} finally {
droppedTheme = undefined;
themeInstalling = false;
}
}}
disabled={themeInstalling}
>
{#if themeInstalling}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" size={18} />
{:else}
Yes, import
{/if}
</Button>
<Button
variant="outline"
onclick={async () => {
themeInstalling = false;
droppedTheme = undefined;
}}
disabled={themeInstalling}
>
No, don't import
</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={trustDialogTheme !== undefined}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center gap-2 border-b border-theme-800 bg-amber-500/10 rounded-t-lg p-4"
>
<ThemeIcon name="warning" set={iconSet} class="size-7 text-amber-300" />
<span class="font-semibold text-lg">
{trustDialogEditor ? 'Edit this theme?' : "Run this theme's code?"}
</span>
</div>
<div
class="mx-3 mt-3 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-100"
>
{#if trustDialogCode === 'v2-svelte'}
<strong>{trustDialogTheme?.name}</strong> contains a custom Svelte layer, so the launcher
will execute JavaScript authored by the theme. Only continue if you trust
<strong>{trustDialogTheme?.author}</strong>
and got the theme from a source you trust. Other API 2.0 themes are data-only and do not need
this prompt.
{:else}
<strong>{trustDialogTheme?.name}</strong> is an API 1.0 theme, so the launcher will execute
its bundled JavaScript. Only continue if you trust
<strong>{trustDialogTheme?.author}</strong>
and got the theme from a source you trust.
{/if}
{#if trustDialogEditor}
<p class="mt-2">
The editor renders custom layers immediately, so this code starts running as soon as the
editor opens.
</p>
{/if}
</div>
<div class="flex items-center justify-center gap-3 p-4">
<Button variant="outline" class="min-w-28" onclick={dismissThemeTrust}>Cancel</Button>
<Button class="min-w-28" onclick={confirmThemeTrust}>
<ThemeIcon name="diagnostics" set={iconSet} class="size-4" />
{trustDialogEditor ? 'Trust and edit' : 'Trust and use'}
</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={restoreCandidate !== undefined}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center gap-2 border-b border-theme-800 bg-black/40 rounded-t-lg p-4"
>
<ThemeIcon name="history" set={iconSet} class="size-7" />
<span class="font-semibold text-lg">Restore previous version?</span>
</div>
<div
class="mx-3 mt-3 rounded-lg border border-theme-800 bg-theme-900 p-3 text-xs leading-relaxed text-muted-foreground"
>
This replaces the current files for <strong class="text-theme-100"
>{restoreCandidate?.theme.name}</strong
>
with the backup from
<strong class="text-theme-100">
{restoreCandidate
? new Date(restoreCandidate.backup.createdAt).toLocaleString()
: 'the previous version'}
</strong>
({restoreCandidate ? formatBytes(restoreCandidate.backup.size) : 'unknown size'}). The active
theme will be reloaded if it was in use.
</div>
<div class="flex items-center justify-center gap-3 p-4">
<Button
variant="outline"
class="min-w-28"
disabled={restoringTheme}
onclick={() => (restoreCandidate = undefined)}
>
Cancel
</Button>
<Button class="min-w-28" disabled={restoringTheme} onclick={confirmThemeRestore}>
{#if restoringTheme}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" />
{:else}
<ThemeIcon name="history" set={iconSet} class="size-4" />
{/if}
Restore
</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root open={pendingExternalUrl !== undefined}>
<AlertDialog.Content
class="bg-theme-950 border-theme-800 p-0 max-w-xl"
escapeKeydownBehavior="ignore"
interactOutsideBehavior="ignore"
>
<div
class="flex flex-col items-center justify-center gap-2 border-b border-theme-800 bg-black/40 rounded-t-lg p-4"
>
<ThemeIcon name="warning" set={iconSet} class="size-7 text-amber-300" />
<span class="font-semibold text-lg">Open external website?</span>
</div>
<div class="mx-3 mt-3 space-y-3">
<p class="text-xs leading-relaxed text-muted-foreground">
<strong class="text-theme-100">{$active_custom_theme?.name ?? 'The active theme'}</strong>
wants to open a website in your browser. Check the full address before continuing.
</p>
<div class="rounded-lg border border-theme-800 bg-black/40 p-3">
<p class="mb-1 text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">
Full URL
</p>
<p class="max-h-32 overflow-auto break-all font-mono text-[11px] text-theme-100">
{pendingExternalUrl}
</p>
</div>
<p class="text-[10px] leading-relaxed text-amber-100/80">
Only continue if you recognize this address. Theme links never open without this
confirmation.
</p>
</div>
<div class="flex items-center justify-center gap-3 p-4">
<Button variant="outline" class="min-w-28" onclick={() => (pendingExternalUrl = undefined)}>
Cancel
</Button>
<Button class="min-w-28" onclick={confirmExternalUrl}>
<ThemeIcon name="arrow-right" set={iconSet} class="size-4" />
Open website
</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
<div class="grid grid-cols-[0.085fr_1fr] h-screen relative" bind:this={dragAndDrop.ref}>
{#if dragAndDrop.isDraggingOverApp && !droppedTheme}
<div
class="fixed top-0 left-0 w-full h-full z-50 flex items-center justify-center bg-black/10 backdrop-blur-sm"
transition:fade={{ duration: 300 }}
>
<div
class="w-[90vw] h-[90vh] flex flex-col items-center justify-center bg-theme-900 border border-theme-800 rounded-lg"
transition:scale={{ start: 0.7, duration: 300 }}
>
<ThemeIcon name="import" set={iconSet} size={64} />
<p>Drag and Drop a .ezpplauncher-theme file here to import</p>
</div>
</div>
{/if}
<div
class="p-3 border-r border-r-theme-900 flex flex-col items-center gap-2 z-10 bg-black/40 backdrop-blur-sm"
>
<div class="bg-primary/30 ring-1 ring-inset ring-white/15 rounded-[1.1rem] p-1.5">
<img class="pointer-events-none" src={Logo} alt="logo" bind:this={ezppLogo} />
</div>
<Badge class="text-[0.5rem] py-0 px-2">{$launcherVersion || 'dev'}</Badge>
<Button
class={sidebarButtonClass('HOME')}
disabled={$launching}
onclick={() => {
if (!$launching) selectedView = 'HOME';
}}
>
<ThemeIcon name="home" set={iconSet} class="size-5!" />
</Button>
<Button
class={sidebarButtonClass('THEMES')}
disabled={$launching}
onclick={() => {
if (!$launching) selectedView = 'THEMES';
}}
>
<ThemeIcon name="themes" set={iconSet} class="size-5!" />
</Button>
<Button
class={sidebarButtonClass('SETTINGS')}
disabled={$launching}
onclick={() => {
if (!$launching) selectedView = 'SETTINGS';
}}
>
<ThemeIcon name="settings" set={iconSet} class="size-5!" />
</Button>
<div class="mt-auto">
<DropdownMenu.Root>
<DropdownMenu.Trigger disabled={$launching}>
<Avatar.Root class="size-10">
<Avatar.Image src="https://a.ez-pp.farm/{$currentUser?.id ?? 0}" />
<Avatar.Fallback class="bg-theme-900">
<ThemeIcon name="loader" set={iconSet} class="animate-spin" size={32} />
</Avatar.Fallback>
</Avatar.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="right" class="w-44">
{#if $currentUser}
<DropdownMenu.Item
class="text-destructive focus:text-destructive text-xs"
onclick={async () => {
await Promise.all([
config.value<string>('username').set(''),
config.value<string>('password').set(''),
]);
sileo.success({
title: 'Logout successful!',
description: 'See you soon!',
fill: '#181825',
});
currentUser.set(undefined);
currentUserInfo.set(undefined);
selectedView = 'HOME';
}}
>
<ThemeIcon name="logout" set={iconSet} class="size-3.5 mr-2" />
Logout
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item class="text-xs" onclick={() => (selectedView = 'LOGIN')}>
<ThemeIcon name="login" set={iconSet} class="size-3.5 mr-2" />
Login
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
<div class="z-10 h-full overflow-hidden">
{#if selectedView === 'HOME'}
<div
class="flex flex-col h-full justify-between overflow-hidden relative select-none"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
y: 5,
opacity: 0,
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
<!-- Header: Branding & Quick Status Pills -->
{#if activeChrome.header !== false}
<div class="px-8 pt-7 pb-2 shrink-0 z-10 flex items-start justify-between gap-4">
<div class="flex flex-col">
<div class="flex items-center gap-2.5">
<span
class="theme-heading text-3xl font-extrabold tracking-tight drop-shadow-md text-theme-50"
>
EZPPLauncher
</span>
</div>
<span class="text-xs font-medium text-white/70 drop-shadow mt-0.5">
{timeGreeting},
<span class="text-white font-semibold">{$currentUser?.name ?? 'Guest'}</span>!
</span>
</div>
</div>
{:else}
<div></div>
{/if}
<!-- Center Area: Configurable Cards / Classic Mode / Canvas -->
{#if activeChrome.layoutMode === 'dashboard'}
{#if activeChrome.playerCard !== false || activeChrome.recentActivity !== false}
<div class="px-8 py-2 my-auto z-10 flex flex-row items-stretch gap-5 w-full max-w-4xl">
{#if activeChrome.playerCard !== false}
{#if $currentUserInfo}
<!-- Enhanced Player Card -->
<div
class="flex-1 min-w-[340px] max-w-[460px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-4.5 flex flex-col justify-between gap-3 shadow-xl"
in:fly={{ duration: $reduceAnimations ? 0 : 350, y: 8, opacity: 0 }}
>
<!-- Player Card Header -->
<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 shadow-md">
<Avatar.Image
src="https://a.ez-pp.farm/{$currentUser?.id ?? 0}"
alt={$currentUser?.name ?? 'Player'}
/>
<Avatar.Fallback class="bg-theme-900 rounded-xl">
<ThemeIcon name="user" set={iconSet} size={22} />
</Avatar.Fallback>
</Avatar.Root>
{#if $currentUserInfo.info.country}
<span
class="absolute -bottom-1 -right-1 flex items-center justify-center text-[11px] leading-none rounded-md bg-black/90 px-1 py-0.5 border border-white/20 shadow"
title={$currentUserInfo.info.country.toUpperCase()}
>
{countryCodeToEmoji($currentUserInfo.info.country)}
</span>
{/if}
</div>
<div class="min-w-0 flex flex-col">
<div class="flex items-center gap-1.5">
<span
class="font-bold text-sm text-theme-50 truncate"
title={$currentUser?.name}
>
{$currentUser?.name ?? 'Player'}
</span>
<button
type="button"
class="text-muted-foreground hover:text-primary transition-colors p-0.5 rounded cursor-pointer"
title="View profile on ez-pp.farm"
onclick={() =>
openURL(`https://ez-pp.farm/u/${$currentUserInfo.info.id}`)}
>
<ExternalLink class="size-3" />
</button>
</div>
<!-- Level & Progress Bar -->
<div class="flex items-center gap-2 mt-1">
<span class="text-[11px] font-semibold text-white/80 shrink-0">
Lv.{currentStats?.level ?? 1}
</span>
<div class="w-24">
<Progress
value={currentStats?.level_progress ?? 0}
max={100}
class="h-1.5 bg-white/10"
/>
</div>
<span class="text-[10px] text-muted-foreground font-mono">
{currentStats?.level_progress ?? 0}%
</span>
</div>
</div>
</div>
<!-- Mode Selector Dropdown -->
<div class="shrink-0">
<Select.Root
type="single"
value={selectedGamemode.toFixed()}
onValueChange={updateGamemode}
>
<Select.Trigger
class="h-8 border-white/10 bg-white/5 hover:bg-white/10 text-xs font-semibold px-2.5 rounded-lg text-theme-100"
>
<div class="flex flex-row items-center gap-1.5">
{#if selectedMode === 0}
<ThemeIcon
name="osu-standard"
set={iconSet}
size={14}
class="text-primary"
/>
{:else if selectedMode === 1}
<ThemeIcon
name="osu-taiko"
set={iconSet}
size={14}
class="text-primary"
/>
{:else if selectedMode === 2}
<ThemeIcon
name="osu-catch"
set={iconSet}
size={14}
class="text-primary"
/>
{:else if selectedMode === 3}
<ThemeIcon
name="osu-mania"
set={iconSet}
size={14}
class="text-primary"
/>
{/if}
<span
>{getGamemodeName(
modeIntToStr(selectedMode),
typeIntToStr(selectedType)
)}</span
>
</div>
</Select.Trigger>
<Select.Content
class="bg-theme-950/95 backdrop-blur border border-theme-900 rounded-lg max-h-72"
>
{#each validModeTypeCombinationsSorted as gamemode (gamemode)}
{@const gamemod = getModeAndTypeFromGamemode(gamemode)}
<Select.Item value={gamemode.toFixed()} class="text-xs">
<div class="flex flex-row gap-2 items-center">
{#if gamemod.mode === 0}
<ThemeIcon name="osu-standard" set={iconSet} size={14} />
{:else if gamemod.mode === 1}
<ThemeIcon name="osu-taiko" set={iconSet} size={14} />
{:else if gamemod.mode === 2}
<ThemeIcon name="osu-catch" set={iconSet} size={14} />
{:else if gamemod.mode === 3}
<ThemeIcon name="osu-mania" set={iconSet} size={14} />
{/if}
{getGamemodeName(
modeIntToStr(gamemod.mode),
typeIntToStr(gamemod.type)
)}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Stat Grid: 4 cards with NumberFlow -->
<div class="grid grid-cols-2 gap-2 mt-1">
<!-- Global & Country Rank -->
<div
class="bg-white/5 border border-white/5 rounded-xl p-2.5 flex flex-col justify-center"
>
<div class="flex items-center justify-between">
<span class="text-[11px] text-muted-foreground font-medium">Rank</span>
{#if currentStats?.country_rank}
<span class="text-[10px] text-primary font-bold">
#{currentStats.country_rank}
{countryCodeToEmoji($currentUserInfo.info.country)}
</span>
{/if}
</div>
<div class="text-base font-bold text-theme-50 mt-0.5">
{#if $reduceAnimations}
#{numberHumanReadable(currentStats?.rank ?? 0)}
{:else}
<NumberFlow trend={0} prefix="#" value={currentStats?.rank ?? 0} />
{/if}
</div>
</div>
<!-- Performance Points -->
<div
class="bg-white/5 border border-white/5 rounded-xl p-2.5 flex flex-col justify-center"
>
<span class="text-[11px] text-muted-foreground font-medium"
>Performance</span
>
<div class="text-base font-bold text-primary mt-0.5">
{#if $reduceAnimations}
{numberHumanReadable(currentStats?.pp ?? 0)}pp
{:else}
<NumberFlow trend={0} suffix="pp" value={currentStats?.pp ?? 0} />
{/if}
</div>
</div>
<!-- Accuracy -->
<div
class="bg-white/5 border border-white/5 rounded-xl p-2.5 flex flex-col justify-center"
>
<span class="text-[11px] text-muted-foreground font-medium">Accuracy</span>
<div class="text-base font-bold text-theme-50 mt-0.5">
{#if $reduceAnimations}
{(currentStats?.acc ?? 0).toFixed(2)}%
{:else}
<NumberFlow
trend={0}
suffix="%"
value={Number((currentStats?.acc ?? 0).toFixed(2))}
/>
{/if}
</div>
</div>
<!-- Play Count -->
<div
class="bg-white/5 border border-white/5 rounded-xl p-2.5 flex flex-col justify-center"
>
<span class="text-[11px] text-muted-foreground font-medium">Plays</span>
<div class="text-base font-bold text-theme-50 mt-0.5">
{#if $reduceAnimations}
{numberHumanReadable(currentStats?.plays ?? 0)}
{:else}
<NumberFlow trend={0} value={currentStats?.plays ?? 0} />
{/if}
</div>
</div>
</div>
</div>
{:else}
<!-- Logged Out Welcome Card -->
<div
class="w-full max-w-md bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-6 flex flex-col gap-4 shadow-xl"
in:fly={{ duration: $reduceAnimations ? 0 : 350, y: 8, opacity: 0 }}
>
<div class="flex items-center gap-3">
<div
class="size-11 rounded-xl bg-primary/20 border border-primary/30 flex items-center justify-center text-primary"
>
<ThemeIcon name="user" set={iconSet} size={22} />
</div>
<div>
<h3 class="font-bold text-sm text-theme-50">Welcome to EZPPFarm</h3>
<p class="text-xs text-muted-foreground">
Sign in to view your live stats and progress
</p>
</div>
</div>
<p class="text-xs text-white/70 leading-relaxed">
Log in to track your global and country rank, PP, accuracy, level progression,
and recent play scores directly on your dashboard.
</p>
<div class="flex items-center gap-2.5 pt-1">
<Button
class="flex-1 h-8 text-xs font-semibold"
onclick={() => {
selectedView = 'LOGIN';
}}
>
Log In
</Button>
<Button
variant="outline"
class="flex-1 h-8 text-xs font-semibold border-white/15 hover:bg-white/10 text-white/80"
onclick={async () => {
await openURL('https://ez-pp.farm/register');
}}
>
Register
</Button>
</div>
</div>
{/if}
{/if}
<!-- Recent Activity Highlight Card -->
{#if activeChrome.recentActivity !== false && $currentUserInfo}
{#if activeEvent}
{@const gradeInfo = gradeBadgeStyle(activeEvent.grade)}
<div
class="flex-1 min-w-[300px] max-w-[420px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden shadow-xl flex flex-col justify-between group relative"
in:fly={{
duration: $reduceAnimations ? 0 : 350,
y: 8,
delay: 80,
opacity: 0,
}}
>
<!-- Top Section: Cover Art Banner -->
<div class="relative h-24 w-full overflow-hidden bg-theme-900/80 shrink-0">
{#if activeEvent.setId}
<img
src="https://assets.ppy.sh/beatmaps/{activeEvent.setId}/covers/cover.jpg"
alt={activeEvent.title}
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onerror={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
{/if}
<div
class="absolute inset-0 bg-gradient-to-t from-black via-black/55 to-black/30"
></div>
<!-- Top badge row -->
<div
class="absolute top-2 left-2.5 right-2.5 flex items-center justify-between gap-1.5"
>
<div class="flex items-center gap-1.5 min-w-0">
<span
class="text-[9px] font-bold tracking-wider uppercase px-2 py-0.5 rounded-full bg-black/70 backdrop-blur-md text-white/90 border border-white/15"
>
Recent Play
</span>
{#if userEvents.length > 1}
<div
class="flex items-center gap-0.5 rounded-full bg-black/60 px-1 py-0.5 border border-white/10 text-[9px] text-white/70"
>
<button
type="button"
class="p-0.5 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
disabled={safeEventIndex === 0}
onclick={() => {
if (safeEventIndex > 0) recentActivityIndex = safeEventIndex - 1;
}}
title="Previous Play"
>
<ChevronLeft class="size-3" />
</button>
<span class="font-mono text-[9px] px-0.5"
>{safeEventIndex + 1}/{userEvents.length}</span
>
<button
type="button"
class="p-0.5 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
disabled={safeEventIndex >= userEvents.length - 1}
onclick={() => {
if (safeEventIndex < userEvents.length - 1)
recentActivityIndex = safeEventIndex + 1;
}}
title="Next Play"
>
<ChevronRight class="size-3" />
</button>
</div>
{/if}
</div>
<!-- Grade badge -->
<div
class="px-2 py-0.5 rounded-md text-xs font-black tracking-wider border {gradeInfo.bg} {gradeInfo.text} {gradeInfo.glow}"
>
{gradeInfo.label}
</div>
</div>
<!-- Bottom song title & artist in cover banner -->
<div class="absolute bottom-1.5 left-2.5 right-2.5 flex flex-col min-w-0">
<span
class="font-bold text-xs text-white drop-shadow truncate"
title={activeEvent.title}
>
{activeEvent.title}
</span>
<span
class="text-[10px] text-white/75 drop-shadow truncate"
title={activeEvent.artist}
>
{activeEvent.artist}
</span>
</div>
</div>
<!-- Middle Content Area: Fills the card with useful gameplay performance metrics -->
<div class="p-3 flex flex-col gap-2.5 flex-1 justify-center">
<!-- Difficulty Pill & Gamemode Tag -->
<div class="flex items-center justify-between gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<span
class="text-[11px] font-semibold text-primary truncate max-w-[200px]"
title={activeEvent.version}
>
[{activeEvent.version}]
</span>
</div>
<div
class="flex items-center gap-1 shrink-0 px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-[10px] text-white/70"
>
{#if activeEvent.mode === 0}
<ThemeIcon
name="osu-standard"
set={iconSet}
size={12}
class="text-primary"
/>
<span>Standard</span>
{:else if activeEvent.mode === 1}
<ThemeIcon
name="osu-taiko"
set={iconSet}
size={12}
class="text-primary"
/>
<span>Taiko</span>
{:else if activeEvent.mode === 2}
<ThemeIcon
name="osu-catch"
set={iconSet}
size={12}
class="text-primary"
/>
<span>Catch</span>
{:else if activeEvent.mode === 3}
<ThemeIcon
name="osu-mania"
set={iconSet}
size={12}
class="text-primary"
/>
<span>Mania</span>
{/if}
</div>
</div>
<!-- Performance Stat Callout Row -->
<div class="grid grid-cols-2 gap-2">
<!-- Rank Achievement Callout -->
<div
class="flex items-center gap-2 p-2 rounded-xl border {activeEvent.rank ===
1
? 'bg-amber-400/10 border-amber-400/30 text-amber-300 shadow-[0_0_12px_rgba(251,191,36,0.15)]'
: activeEvent.rank && activeEvent.rank <= 3
? 'bg-slate-300/10 border-slate-300/25 text-slate-200'
: 'bg-white/5 border-white/5 text-white/90'}"
>
{#if activeEvent.rank === 1}
<Trophy class="size-4 shrink-0 text-amber-400" />
<div class="flex flex-col min-w-0">
<span
class="text-[9px] uppercase tracking-wider text-amber-300/80 font-bold"
>Top Rank</span
>
<span class="text-xs font-black truncate">#1 Global</span>
</div>
{:else if activeEvent.rank && activeEvent.rank <= 3}
<Medal class="size-4 shrink-0 text-slate-300" />
<div class="flex flex-col min-w-0">
<span
class="text-[9px] uppercase tracking-wider text-slate-300/80 font-semibold"
>Podium</span
>
<span class="text-xs font-bold truncate"
>Rank #{activeEvent.rank}</span
>
</div>
{:else if activeEvent.rank}
<div
class="size-4 shrink-0 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold text-[10px]"
>
#
</div>
<div class="flex flex-col min-w-0">
<span
class="text-[9px] uppercase tracking-wider text-muted-foreground"
>Placement</span
>
<span class="text-xs font-bold truncate"
>Rank #{activeEvent.rank}</span
>
</div>
{:else}
<div class="flex flex-col min-w-0">
<span
class="text-[9px] uppercase tracking-wider text-muted-foreground"
>Score</span
>
<span class="text-xs font-bold truncate">Completed</span>
</div>
{/if}
</div>
<!-- Status & Relative Time Callout -->
<div
class="flex flex-col justify-center p-2 rounded-xl bg-white/5 border border-white/5 min-w-0"
>
<div class="flex items-center gap-1">
{#if activeEvent.event === 'GAINED'}
<span
class="flex items-center gap-0.5 text-[10px] font-bold text-emerald-400"
>
<TrendingUp class="size-3" /> Gained
</span>
{:else if activeEvent.event === 'LOST'}
<span
class="flex items-center gap-0.5 text-[10px] font-bold text-rose-400"
>
<TrendingDown class="size-3" /> Lost
</span>
{:else}
<span class="text-[10px] font-medium text-white/80"
>Play Achieved</span
>
{/if}
</div>
<span
class="text-[10px] text-muted-foreground font-mono flex items-center gap-1 mt-0.5 truncate"
>
<Clock class="size-2.5 shrink-0" />
{formatRelativeTime(activeEvent.time)}
</span>
</div>
</div>
<!-- Mini Feed of Recent Plays (clickable chips) -->
{#if userEvents.length > 1}
<div class="flex items-center gap-1.5 pt-0.5 overflow-x-auto no-scrollbar">
{#each userEvents.slice(0, 4) as event, idx (`${event.time}-${idx}`)}
{@const eventGrade = gradeBadgeStyle(event.grade)}
<button
type="button"
class="flex items-center gap-1 px-2 py-1 rounded-lg border transition-all text-left cursor-pointer shrink-0 {idx ===
safeEventIndex
? 'bg-primary/20 border-primary text-white shadow-sm'
: 'bg-white/5 border-white/5 text-muted-foreground hover:bg-white/10 hover:text-white'}"
onclick={() => (recentActivityIndex = idx)}
title="{event.title} [{event.version}]"
>
<span class="text-[9px] font-black {eventGrade.text}"
>{eventGrade.label}</span
>
<span class="text-[10px] font-medium truncate max-w-[70px]">
{event.version}
</span>
</button>
{/each}
</div>
{/if}
</div>
<!-- Event Details Footer Action Bar -->
<div
class="px-3 py-2 flex items-center justify-between gap-2 border-t border-white/5 bg-black/20 shrink-0"
>
<span class="text-[10px] text-muted-foreground font-mono">
b/{activeEvent.mapId}
</span>
<div class="flex items-center gap-1.5">
<button
type="button"
class="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-xs font-medium text-white/80 hover:text-white border border-white/10 transition-colors cursor-pointer"
onclick={() =>
openURL(
`https://ez-pp.farm/beatmapsets/${activeEvent.setId}/${activeEvent.mapId}`
)}
title="View Beatmap on ez-pp.farm"
>
<span>Map</span>
<ExternalLink class="size-3" />
</button>
</div>
</div>
</div>
{:else}
<!-- No recent activity placeholder -->
<div
class="flex-1 min-w-[280px] max-w-[340px] bg-black/40 backdrop-blur-md rounded-2xl border border-white/10 p-5 flex flex-col items-center justify-center text-center shadow-xl"
in:fly={{
duration: $reduceAnimations ? 0 : 350,
y: 8,
delay: 80,
opacity: 0,
}}
>
<div
class="size-10 rounded-full bg-white/5 flex items-center justify-center mb-2.5 text-white/40"
>
<ThemeIcon name="osu-standard" set={iconSet} size={20} />
</div>
<span class="text-xs font-semibold text-white/80">No Recent Plays</span>
<p class="text-[11px] text-muted-foreground mt-1 max-w-[220px]">
Launch osu! and play a beatmap to see your latest performance highlighted
here.
</p>
</div>
{/if}
{/if}
</div>
{/if}
{:else if activeChrome.layoutMode === 'classic'}
<!-- Classic Compact Mode -->
<div class="px-8 py-2 my-auto z-10 flex flex-col justify-end">
{#if activeChrome.playerCard !== false}
{#if $currentUserInfo}
<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 p-3 mb-2"
in:fly={{ duration: $reduceAnimations ? 0 : 350, y: 5, opacity: 0 }}
>
<Select.Root
type="single"
value={selectedGamemode.toFixed()}
onValueChange={updateGamemode}
>
<Select.Trigger
class="border-theme-800/90 bg-theme-900/90 hover:bg-theme-800/90 text-muted-foreground! font-semibold"
>
<div class="flex flex-row items-center gap-2">
{#if selectedMode === 0}
<ThemeIcon
name="osu-standard"
set={iconSet}
size={16}
class="text-theme-200"
/>
{:else if selectedMode === 1}
<ThemeIcon
name="osu-taiko"
set={iconSet}
size={16}
class="text-theme-200"
/>
{:else if selectedMode === 2}
<ThemeIcon
name="osu-catch"
set={iconSet}
size={16}
class="text-theme-200"
/>
{:else if selectedMode === 3}
<ThemeIcon
name="osu-mania"
set={iconSet}
size={16}
class="text-theme-200"
/>
{/if}
{getGamemodeName(modeIntToStr(selectedMode), typeIntToStr(selectedType))}
</div>
</Select.Trigger>
<Select.Content
class="bg-theme-950 border border-theme-900 rounded-lg max-h-72"
>
{#each validModeTypeCombinationsSorted as gamemode (gamemode)}
{@const gamemod = getModeAndTypeFromGamemode(gamemode)}
<Select.Item value={gamemode.toFixed()}>
<div class="flex flex-row gap-2 items-center">
{#if gamemod.mode === 0}
<ThemeIcon name="osu-standard" set={iconSet} size={16} />
{:else if gamemod.mode === 1}
<ThemeIcon name="osu-taiko" set={iconSet} size={16} />
{:else if gamemod.mode === 2}
<ThemeIcon name="osu-catch" set={iconSet} size={16} />
{:else if gamemod.mode === 3}
<ThemeIcon name="osu-mania" set={iconSet} size={16} />
{/if}
{getGamemodeName(
modeIntToStr(gamemod.mode),
typeIntToStr(gamemod.type)
)}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="grid grid-cols-2 gap-2 w-full m-3">
<div class="flex flex-col">
<span class="text-xs text-muted-foreground font-semibold">Rank</span>
<div class="flex items-center h-full font-semibold text-theme-50">
<span>#{numberHumanReadable(currentStats?.rank ?? 0)}</span>
</div>
</div>
<div class="flex flex-col">
<span class="text-xs text-muted-foreground font-semibold">PP</span>
<div class="flex items-center h-full font-semibold text-primary">
<span>{numberHumanReadable(currentStats?.pp ?? 0)}pp</span>
</div>
</div>
<div class="flex flex-col">
<span class="text-xs text-muted-foreground font-semibold">Accuracy</span>
<div class="flex items-center h-full font-semibold text-theme-50">
<span>{(currentStats?.acc ?? 0).toFixed(2)}%</span>
</div>
</div>
<div class="flex flex-col">
<span class="text-xs text-muted-foreground font-semibold">Playcount</span>
<div class="flex items-center h-full font-semibold text-theme-50">
<span>{numberHumanReadable(currentStats?.plays ?? 0)}</span>
</div>
</div>
</div>
</div>
{:else}
<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 mb-2"
>
<div class="flex flex-col items-center gap-1 text-center">
<span class="font-semibold text-theme-50 text-sm">See your stats here</span>
<span class="text-xs text-muted-foreground"
>Log in with your EZPPFarm account to track your rank.</span
>
</div>
<Button class="h-7 text-xs w-full" onclick={() => (selectedView = 'LOGIN')}
>Log In</Button
>
</div>
{/if}
{/if}
</div>
{/if}
<!-- Bottom Dock -->
{#if activeChrome.dock !== false}
<div
class="relative z-10 px-8 py-3.5 flex items-center justify-between bg-black/45 backdrop-blur-md border-t border-white/10 shrink-0"
>
{#if activeChrome.dockTelemetry !== false}
<div class="flex items-center gap-3 flex-wrap">
{#if $osuInstallationPath !== ''}
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5"
>
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
<span class="text-[11px] text-white/80 font-medium">
{#if !$beatmapSets && $beatmapSets !== 0}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" size={12} />
{:else}
{numberHumanReadable($beatmapSets ?? 0)}
{/if}
</span>
</div>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5"
>
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
<span class="text-[11px] text-white/80 font-medium">
{#if !$skinsCount && $skinsCount !== 0}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" size={12} />
{:else}
{numberHumanReadable($skinsCount ?? 0)}
{/if}
</span>
</div>
{#if $currentSkin}
<span
class="text-[10px] px-2.5 py-1 rounded-full bg-primary/20 text-primary border border-primary/30 max-w-[180px] truncate"
title={$currentSkin}
>
{$currentSkin}
</span>
{/if}
{/if}
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/5"
>
{#if $serverConnectionFails > 1}
<ThemeIcon name="wifi-off" set={iconSet} class="size-3.5 text-red-400" />
{:else}
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
{/if}
<span class="relative text-[11px] text-white/80 font-medium">
{#if !$serverPing || $serverConnectionFails > 1}
<ThemeIcon name="loader" set={iconSet} class="size-3 animate-spin" />
{:else}
{#if $reduceAnimations}
<span>{$serverPing}ms</span>
{:else}
<NumberFlow value={$serverPing ?? 0} trend={0} suffix="ms" />
{/if}
{/if}
</span>
</div>
{#if $osuInstallationPath !== ''}
<span
class="text-[10px] px-2 py-0.5 rounded-full bg-white/10 text-white/60 border border-white/5"
>
{#if $osuStream}
{releaseStreamToReadable($osuStream)}
{:else}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" size={10} />
{/if}
</span>
<span
class="text-[10px] px-2 py-0.5 rounded-full bg-white/10 text-white/60 font-mono border border-white/5"
>
{#if $osuBuild}
{$osuBuild}
{:else}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" size={10} />
{/if}
</span>
{/if}
</div>
{:else}
<div></div>
{/if}
<DownloadButton
downloading={$launching}
{progress}
text={$launching
? downloadingEZPPFiles
? 'Downloading...'
: cleanup
? 'Cleaning up...'
: 'Launching...'
: $serverConnectionFails > 1
? 'No connection'
: $osuInstallationPath === ''
? 'Hmmm..'
: 'Launch'}
subtext={$launching && !cleanup
? launchInfo
: $osuInstallationPath === ''
? 'osu! path is not set!'
: $serverConnectionFails > 1
? 'No connection...'
: undefined}
disabled={$osuInstallationPath === '' || $serverConnectionFails > 1}
{iconSet}
onClick={async () => await launch()}
/>
</div>
{:else if !hasThemeLaunchButton}
<!-- REQUIRED LAUNCH BUTTON: Dock is hidden and theme forgot a launch button -->
<div class="fixed bottom-6 right-6 z-40" in:scale>
<DownloadButton
downloading={$launching}
{progress}
text={$launching
? downloadingEZPPFiles
? 'Downloading...'
: cleanup
? 'Cleaning up...'
: 'Launching...'
: $serverConnectionFails > 1
? 'No connection'
: $osuInstallationPath === ''
? 'Hmmm..'
: 'Launch'}
subtext={$launching && !cleanup
? launchInfo
: $osuInstallationPath === ''
? 'osu! path is not set!'
: $serverConnectionFails > 1
? 'No connection...'
: undefined}
disabled={$osuInstallationPath === '' || $serverConnectionFails > 1}
{iconSet}
onClick={async () => await launch()}
/>
</div>
{/if}
</div>
{:else if selectedView === 'SETTINGS'}
<div
class="h-screen w-full bg-black/20 backdrop-blur-sm"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
y: 5,
opacity: 0,
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
<div class="flex h-screen w-full items-center justify-center px-6 pt-10 pb-8">
<div
class="flex h-full max-h-[560px] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-theme-800/60 bg-theme-950/75 shadow-2xl backdrop-blur-xl"
in:scale={{ duration: $reduceAnimations ? 0 : 260, start: 0.98, opacity: 0 }}
>
<header class="shrink-0 border-b border-theme-800/50 px-5 pt-4 pb-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="text-lg font-semibold tracking-tight">Settings</h1>
{#if activeSettingsTab === 'logging'}
<Badge variant={$loggingEnabled ? 'secondary' : 'outline'} class="text-[9px]">
{$loggingEnabled ? 'Enabled' : 'Disabled'}
</Badge>
{/if}
</div>
<p class="mt-0.5 truncate text-[11px] text-muted-foreground">
{activeSettings.description}
</p>
</div>
<div
class="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 text-[10px] font-medium text-emerald-300"
title="Changes are saved automatically"
>
<ThemeIcon name="check" set={iconSet} class="size-3" />
Saved automatically
</div>
</div>
<nav class="mt-3 flex min-w-0 items-center gap-0.5" aria-label="Settings sections">
{#each settingsTabs as tab (tab.id)}
<button
type="button"
class={settingsTabClass(tab.id)}
aria-current={activeSettingsTab === tab.id ? 'page' : undefined}
onclick={() => (activeSettingsTab = tab.id)}
>
<ThemeIcon name={tab.icon} set={iconSet} class="size-3.5 shrink-0" />
<span>{tab.label}</span>
</button>
{/each}
</nav>
</header>
<div class="min-h-0 flex-1 px-5 pt-4 pb-1">
{#key activeSettingsTab}
<div
class="h-full"
in:fly={{ duration: $reduceAnimations ? 0 : 240, y: 8, opacity: 0 }}
>
<ScrollContainer class="pr-2">
<div class="pb-4">
<div
class="grid items-start gap-4 {activeSettingsTab === 'diagnostics'
? ''
: 'md:grid-cols-[minmax(0,1fr)_190px]'}"
>
<div class="min-w-0">
{#if activeSettingsTab === 'gameplay'}
<section>
<div
class="divide-y divide-theme-800/50 overflow-hidden rounded-xl border border-theme-800/60 bg-theme-900/25 px-4"
>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-patch">Patching</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Shows misses in Relax and Autopilot.
{#if $platform === 'macos'}
Runs through your osu! Wine prefix. Only the experimental
patcher stream needs .NET 8 installed there.
{/if}
</p>
</div>
<Checkbox
id="setting-patch"
checked={$patch}
onCheckedChange={async (e) => {
patch.set(e);
}}
class="mt-0.5"
/>
</div>
{#if $platform === 'windows'}
<div class="py-3">
<Label class="text-sm">Patcher release stream</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Test different versions of the patcher. The experimental
stream requires the .NET 8 Desktop Runtime; stable does not.
</p>
<Select.Root
type="single"
bind:value={$launcherStream}
onValueChange={async (newStream) => {
// Only the experimental patcher build targets .NET 8.
if (newStream === 'experimental') {
const isNet8Installed = await hasNet8();
if (!isNet8Installed) {
launcherStream.set('stable');
sileo.error({
title: 'Hmm...',
description:
'.NET 8.0 Desktop Runtime is required for the experimental patcher stream.',
button: {
title: 'Download .NET 8.0',
onClick: async () =>
await openURL(
'https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-8.0.22-windows-x64-installer'
),
},
});
return;
}
}
await config.value<string>('patcherStream').set(newStream);
launcherStream.set(newStream);
}}
>
<Select.Trigger class="mt-2.5 border-theme-800 bg-theme-950">
<div
class="flex flex-row items-center gap-2 font-normal text-foreground"
>
{$launcherStream}
</div>
</Select.Trigger>
<Select.Content
class="rounded-lg border border-theme-950 bg-theme-950"
>
{#each $launcherStreams as stream (stream)}
<Select.Item value={stream}>
<div class="flex flex-row items-center gap-2">
{stream}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{#if $platform !== 'windows'}
<div class="flex items-start justify-between gap-6 py-3">
<div class="min-w-0">
<Label class="text-sm">Patcher stream</Label>
<p
class="mt-0.5 text-xs leading-relaxed text-muted-foreground"
>
The experimental stream is Windows-only, so
{$platform === 'macos' ? 'macOS' : 'Linux'} uses stable.
</p>
</div>
<Badge variant="outline">stable</Badge>
</div>
{/if}
</div>
</section>
{:else if activeSettingsTab === 'appearance'}
<section>
<div
class="divide-y divide-theme-800/50 overflow-hidden rounded-xl border border-theme-800/60 bg-theme-900/25 px-4"
>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-custom-cursor"
>Lazer-style cursor</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Enable a custom cursor in the launcher, like the lazer build
of osu!.
</p>
</div>
<Checkbox
id="setting-custom-cursor"
checked={$customCursor}
onCheckedChange={async (e) => {
if (!e) {
cursorSmoothening.set(false);
}
customCursor.set(e);
}}
class="mt-0.5"
/>
</div>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-cursor-smoothening"
>Cursor smoothening</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Makes the custom cursor movement smoother.
</p>
</div>
<Checkbox
id="setting-cursor-smoothening"
checked={$cursorSmoothening}
onCheckedChange={async (e) => {
if (!$customCursor) return;
cursorSmoothening.set(e);
}}
disabled={!$customCursor}
class="mt-0.5"
/>
</div>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-reduce-animations"
>Reduce animations</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Disables some launcher animations to improve performance on
low-end devices.
</p>
</div>
<Checkbox
id="setting-reduce-animations"
checked={$reduceAnimations}
onCheckedChange={async (e) => {
reduceAnimations.set(e);
}}
class="mt-0.5"
/>
</div>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-game-optimization"
>Game optimization & low latency</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Boosts osu! process priority to High and requests maximum
sub-millisecond Windows timer resolution (1ms) during gameplay
to reduce input latency.
</p>
</div>
<Checkbox
id="setting-game-optimization"
checked={$gameOptimization}
onCheckedChange={async (e) => {
gameOptimization.set(e);
}}
class="mt-0.5"
/>
</div>
</div>
</section>
{:else if activeSettingsTab === 'integrations'}
<section>
<div
class="divide-y divide-theme-800/50 overflow-hidden rounded-xl border border-theme-800/60 bg-theme-900/25 px-4"
>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-rich-presence"
>Discord Rich Presence</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Let other Discord users see what you are doing right now 👀
</p>
</div>
<div class="mt-0.5 flex items-center gap-3">
{#if $presenceLoading}
<ThemeIcon
name="loader"
set={iconSet}
class="size-4 animate-spin text-muted-foreground"
/>
{/if}
<Checkbox
id="setting-rich-presence"
bind:checked={$discordPresence}
disabled={$presenceLoading}
/>
</div>
</div>
<div class="flex items-center justify-between gap-6 py-3">
<div class="min-w-0">
<Label class="text-sm">Activity Embed Customization</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Customize details, actions, templates, interactive buttons,
and preview live Discord cards.
</p>
</div>
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 px-3 text-xs shrink-0"
onclick={openDiscordPresenceEditor}
>
<ThemeIcon name="palette" set={iconSet} class="size-3.5" />
Customize Activity
</Button>
</div>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-tracking"
>App tracking</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Allow anonymous usage data to be collected to help improve the
application.
</p>
</div>
<Checkbox
id="setting-tracking"
checked={$trackingEnabled}
onCheckedChange={async (e) => {
trackingEnabled.set(e);
await config.value<boolean>('tracking_consent').set(e);
}}
class="mt-0.5"
/>
</div>
</div>
</section>
{:else if activeSettingsTab === 'installation'}
<section>
<div
class="divide-y divide-theme-800/50 overflow-hidden rounded-xl border border-theme-800/60 bg-theme-900/25 px-4"
>
<div class="py-3">
<Label class="text-sm" for="setting-osu-path"
>osu! installation path</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
The path to your osu! installation.
{#if $platform === 'macos'}
Select your <code>osu!.app</code> bundle directly. The launcher
will locate osu! inside the Wineskin wrapper for you.
{/if}
</p>
<div class="mt-2.5 flex gap-2">
<Input
id="setting-osu-path"
class="flex-1 border-theme-800 bg-theme-950"
type="text"
value={$osuInstallationPath}
placeholder="Path to osu! installation"
readonly
/>
<Button
variant="outline"
class="shrink-0 border-theme-800 bg-theme-950"
onclick={browse_osu_installation}
>
<ThemeIcon name="folder" set={iconSet} class="size-4" />
Browse
</Button>
</div>
</div>
{#if $platform === 'windows'}
<div class="py-3">
<div class="flex items-start justify-between gap-8">
<div class="min-w-0">
<Label class="text-sm" for="setting-otd"
>OpenTabletDriver</Label
>
<p
class="mt-0.5 text-xs leading-relaxed text-muted-foreground"
>
Automatically start OpenTabletDriver when launching osu!.
</p>
</div>
<Checkbox
id="setting-otd"
bind:checked={$openTabletDriverEnabled}
class="mt-0.5"
/>
</div>
<div class="mt-2.5 flex gap-2">
<Input
class="flex-1 border-theme-800 bg-theme-950"
type="text"
value={$openTabletDriverPath}
placeholder="Path to OpenTabletDriver.Daemon.exe"
readonly
disabled={!$openTabletDriverEnabled}
/>
<Button
variant="outline"
class="shrink-0 border-theme-800 bg-theme-950"
disabled={!$openTabletDriverEnabled}
onclick={browse_otd}
>
<ThemeIcon name="folder" set={iconSet} class="size-4" />
Browse
</Button>
</div>
</div>
{/if}
</div>
</section>
{:else if activeSettingsTab === 'diagnostics'}
<section>
<DiagnosticsPanel onRepair={openSetupWizard} />
</section>
{:else if activeSettingsTab === 'logging'}
<section>
<div
class="divide-y divide-theme-800/50 overflow-hidden rounded-xl border border-theme-800/60 bg-theme-900/25 px-4"
>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-logging"
>Save logs to disk</Label
>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Capture launcher, osu! and patcher output. Rotated logs are
bundled into one ZIP per session, and the last five bundles
are kept.
</p>
</div>
<Checkbox
id="setting-logging"
checked={$loggingEnabled}
onCheckedChange={(enabled) => loggingEnabled.set(enabled)}
class="mt-0.5"
/>
</div>
<div class="flex flex-wrap items-center justify-between gap-4 py-3">
<div class="min-w-0">
<Label class="text-sm">Log bundles</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Stored in
<span class="font-mono text-[11px] text-theme-300"
>{$platform === 'windows'
? '%USERPROFILE%\\.ezpplauncher\\logs'
: '~/.ezpplauncher/logs'}</span
>.
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={() => (logViewerOpen = true)}
>
<ThemeIcon name="logs" set={iconSet} class="size-4" />
View logs
</Button>
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
onclick={async () => {
try {
await openLogsFolder();
} catch (error) {
console.error(error);
sileo.error({
title: 'Could not open the logs folder',
});
}
}}
>
<ThemeIcon name="folder" set={iconSet} class="size-4" />
Open folder
</Button>
</div>
</div>
</div>
</section>
{/if}
</div>
{#if activeSettingsTab !== 'diagnostics'}
<aside class="space-y-3">
<div
class="rounded-xl border border-theme-800/60 bg-theme-900/25 p-3.5"
in:fly={{
duration: $reduceAnimations ? 0 : 240,
delay: $reduceAnimations ? 0 : 60,
y: 8,
opacity: 0,
}}
>
<p
class="flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.12em] text-muted-foreground uppercase"
>
<ThemeIcon name="info" set={iconSet} class="size-3.5" /> Status
</p>
<dl class="mt-3 space-y-2 text-[11px]">
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">Launcher</dt>
<dd class="font-medium">{$launcherVersion || 'dev'}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">osu!</dt>
<dd class="truncate font-medium">
{$osuBuild || 'Not detected'}
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">Stream</dt>
<dd class="font-medium">
{$osuStream ? releaseStreamToReadable($osuStream) : '—'}
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">Patching</dt>
<dd class="font-medium {$patch ? 'text-emerald-400' : ''}">
{$patch ? 'On' : 'Off'}
</dd>
</div>
</dl>
</div>
<div
class="rounded-xl border border-theme-800/60 bg-theme-900/25 p-3.5"
in:fly={{
duration: $reduceAnimations ? 0 : 240,
delay: $reduceAnimations ? 0 : 120,
y: 8,
opacity: 0,
}}
>
<p
class="flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.12em] text-muted-foreground uppercase"
>
<ThemeIcon name="wrench" set={iconSet} class="size-3.5" /> Quick actions
</p>
<div class="mt-2 flex flex-col gap-0.5">
<Button
variant="ghost"
size="sm"
class="justify-start px-2 text-xs text-muted-foreground hover:text-theme-50"
onclick={openSetupWizard}
>
<ThemeIcon name="wrench" set={iconSet} class="size-3.5" /> Repair setup
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start px-2 text-xs text-muted-foreground hover:text-theme-50"
onclick={() => (activeSettingsTab = 'diagnostics')}
>
<ThemeIcon name="diagnostics" set={iconSet} class="size-3.5" /> Open
diagnostics
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start px-2 text-xs text-muted-foreground hover:text-theme-50"
onclick={async () => {
try {
await openLogsFolder();
} catch (error) {
console.error(error);
sileo.error({ title: 'Could not open the logs folder' });
}
}}
>
<ThemeIcon name="folder" set={iconSet} class="size-3.5" /> Open logs
folder
</Button>
</div>
</div>
</aside>
{/if}
</div>
</div>
</ScrollContainer>
</div>
{/key}
</div>
</div>
</div>
</div>
{:else if selectedView === 'LOGIN'}
<div
class="h-screen w-full flex flex-col items-center justify-center bg-black/20 backdrop-blur-sm"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
y: 5,
opacity: 0,
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
<form onsubmit={performLogin} class="w-96">
<div class="flex flex-col items-center justify-center mb-8">
<span class="text-xl font-semibold">Login to EZPPFarm</span>
<span class="text-xs text-muted-foreground"
>Use your EZPPFarm account to login to EZPPLauncher</span
>
</div>
<div class="mb-4">
<Label for="username" class="block text-sm font-medium">Username</Label>
<Input
class="mt-4 w-full bg-theme-900 border-theme-800"
type="text"
id="username"
bind:value={username}
disabled={loginIsLoading}
autocomplete="off"
autocorrect="off"
/>
</div>
<div class="mb-4">
<Label for="password" class="block text-sm font-medium">Password</Label>
<Input
class="mt-4 w-full bg-theme-900 border-theme-800"
type="password"
id="password"
bind:value={password}
disabled={loginIsLoading}
autocomplete="off"
autocorrect="off"
/>
</div>
<Button class="w-full" type="submit" disabled={loginIsLoading}>
{#if loginIsLoading}
<ThemeIcon name="loader" set={iconSet} class="animate-spin" />
{:else}
Login
{/if}
</Button>
</form>
</div>
{:else if selectedView === 'THEMES'}
<div
class="h-screen w-full bg-black/20 backdrop-blur-sm"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
y: 5,
opacity: 0,
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
<div class="flex h-screen w-full items-center justify-center px-6 pt-10 pb-8">
<div
class="flex h-full max-h-[560px] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-theme-800/60 bg-theme-950/75 shadow-2xl backdrop-blur-xl"
in:scale={{ duration: $reduceAnimations ? 0 : 260, start: 0.98, opacity: 0 }}
>
<header class="shrink-0 border-b border-theme-800/50 px-5 pt-4 pb-3">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-4">
<div class="min-w-0">
<div class="flex items-baseline gap-2">
<h1 class="text-lg font-semibold tracking-tight">Themes</h1>
</div>
<p class="mt-0.5 text-[11px] text-muted-foreground">Make it feel like yours.</p>
</div>
<!-- Tab Switcher: Installed vs Community Workshop -->
<div class="flex items-center rounded-lg border border-theme-800/60 bg-theme-900/50 p-0.5">
<button
type="button"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab === 'library'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-white'}"
onclick={() => (activeThemesTab = 'library')}
>
<ThemeIcon name="library" set={iconSet} class="size-3.5" />
<span>Installed</span>
<span class="rounded-full bg-black/30 px-1.5 py-0.2 text-[9px] tabular-nums">
{$custom_themes.length}
</span>
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab === 'workshop'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-white'}"
onclick={() => (activeThemesTab = 'workshop')}
>
<Globe class="size-3.5" />
<span>Community Workshop</span>
</button>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-theme-100"
title="Refresh themes"
aria-label="Refresh themes"
onclick={refreshThemes}
disabled={refreshingThemes}
>
<ThemeIcon
name="refresh"
set={iconSet}
class="size-3.5 {refreshingThemes ? 'animate-spin' : ''}"
/>
</Button>
{#if $active_custom_theme}
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-theme-100"
title="Reload active theme"
aria-label="Reload active theme"
onclick={reloadActiveTheme}
disabled={reloadingActiveTheme}
>
<ThemeIcon
name="rotate"
set={iconSet}
class="size-3.5 {reloadingActiveTheme ? 'animate-spin' : ''}"
/>
</Button>
{/if}
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-theme-100"
title="Open themes folder"
aria-label="Open themes folder"
onclick={async () => {
try {
await openThemesFolder();
} catch (error) {
console.error(error);
sileo.error({ title: 'Could not open the themes folder' });
}
}}
>
<ThemeIcon name="folder" set={iconSet} class="size-3.5" />
</Button>
<Button class="h-8 gap-1.5 text-xs" size="sm" onclick={() => openThemeEditor()}>
<ThemeIcon name="plus" set={iconSet} class="size-3.5" />
New theme
</Button>
</div>
</div>
{#if activeThemesTab === 'library'}
<div class="mt-3 flex items-center justify-between gap-3">
<nav class="flex min-w-0 items-center gap-0.5" aria-label="Theme filters">
{#each themeFilterOptions as option (option.id)}
<button
type="button"
class={themeFilterClass(option.id)}
aria-current={themeFilter === option.id ? 'page' : undefined}
onclick={() => (themeFilter = option.id)}
>
<ThemeIcon name={option.icon} set={iconSet} class="size-3.5 shrink-0" />
<span>{option.label}</span>
<span class="text-[8px] tabular-nums text-muted-foreground/60">
{themeCounts[option.id]}
</span>
</button>
{/each}
</nav>
<div class="relative w-44 shrink-0">
<ThemeIcon
name="search"
set={iconSet}
class="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="h-8 border-theme-800 bg-theme-950/80 pl-7 text-[11px]"
placeholder="Search themes..."
bind:value={themeSearch}
/>
</div>
</div>
{/if}
</header>
<div class="min-h-0 flex-1 px-5 pt-4 pb-1">
{#if activeThemesTab === 'workshop'}
<CommunityWorkshopView
onApplyTheme={applyInstalledTheme}
onRequestTrust={(theme) => requestThemeTrust(theme, false)}
/>
{:else}
<ScrollContainer class="pr-2">
{#if filteredThemes.length > 0}
{#key themeFilter}
<div class="grid grid-cols-3 gap-3 pb-4 pr-1">
{#each filteredThemes as theme, index (theme.folder_name)}
{@const isActive = $active_custom_theme?.folder_name === theme.folder_name}
<div
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/15'
: 'border-theme-800/60'} {$reduceAnimations
? ''
: 'transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/25'}"
in:fly={{
duration: $reduceAnimations ? 0 : 260,
delay: $reduceAnimations ? 0 : Math.min(index * 35, 210),
y: 8,
opacity: 0,
}}
>
<div
class="relative h-36 w-full shrink-0 overflow-hidden bg-theme-950/60"
>
<div
class="h-full w-full {$reduceAnimations
? ''
: 'transition-transform duration-500 ease-out group-hover:scale-[1.03]'} {theme.nsfw &&
!revealedNsfw.includes(theme.name)
? 'scale-105 blur-2xl'
: ''}"
>
{#if theme.preview}
<img
src={theme.preview}
alt="Preview of {theme.name}"
class="pointer-events-none h-full w-full object-cover object-center select-none"
/>
{:else if themeBackgroundVideo(theme)}
<video
src={themeBackgroundVideo(theme)}
class="pointer-events-none h-full w-full object-cover object-center"
autoplay
loop
muted
playsinline
></video>
{:else if theme.definition}
<div
class="h-full w-full"
style={themeV2ThumbnailStyle(theme.definition, theme.assets)}
></div>
{:else}
<img
src={DefaultThemePreview}
alt="Preview of {theme.name}"
class="pointer-events-none h-full w-full object-cover object-center select-none"
/>
{/if}
</div>
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-theme-950/75 via-transparent to-transparent"
></div>
{#if isActive}
<Badge class="absolute top-1.5 left-1.5 text-[9px]">In use</Badge>
{/if}
{#if theme.name !== 'Default' && theme.folder_name !== 'default' && theme.apiVersion}
<Badge
variant="secondary"
class="absolute top-1.5 right-1.5 bg-black/50 px-1.5 py-0 text-[9px] backdrop-blur-sm"
>
API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'}
</Badge>
{/if}
{#if theme.nsfw && !revealedNsfw.includes(theme.name)}
<button
type="button"
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-1 bg-black/20 text-white transition hover:bg-black/30"
onclick={() => toggleNsfwPreview(theme.name)}
>
<ThemeIcon name="eye" set={iconSet} class="size-5" />
<span class="text-[9px] font-semibold uppercase tracking-wide">
Adult preview
</span>
<span class="text-[8px] text-white/70">Click to reveal</span>
</button>
{:else if theme.nsfw}
<button
type="button"
class="absolute bottom-1.5 left-1.5 z-10 flex items-center gap-1 rounded-full bg-black/60 px-1.5 py-0.5 text-[8px] font-medium text-white/80 backdrop-blur-sm hover:bg-black/75 hover:text-white"
onclick={() => toggleNsfwPreview(theme.name)}
>
<ThemeIcon name="eye-off" set={iconSet} class="size-3" />
Hide preview
</button>
{/if}
{#if theme.nsfw}
<Badge
variant="destructive"
class="absolute right-1.5 bottom-1.5 px-1.5 py-0 text-[9px]"
title="Adult content"
>
18+
</Badge>
{/if}
</div>
<div class="flex flex-1 flex-col gap-1.5 p-2.5">
<div class="flex items-start justify-between gap-1.5">
<div class="min-w-0">
<span
class="block truncate text-[13px] leading-tight font-semibold"
>
{theme.name}
</span>
<span
class="mt-0.5 block truncate text-[10px] text-muted-foreground"
>
by {theme.author}
</span>
</div>
<span
class="shrink-0 rounded-full bg-theme-800/50 px-1.5 py-0.5 text-[9px] whitespace-nowrap tabular-nums text-muted-foreground"
>
{#if theme.updateAvailable && theme.availableVersion}
v{theme.version} <span class="text-primary">→</span>
v{theme.availableVersion}
{:else}
v{theme.version}
{/if}
</span>
</div>
{#if theme.description}
<p
class="line-clamp-2 text-[10px] leading-snug text-muted-foreground"
>
{theme.description}
</p>
{/if}
<div class="mt-auto flex items-center gap-1 pt-0.5">
<Button
class="relative h-8 min-w-0 flex-1 overflow-hidden px-2 text-xs {theme.status ===
'downloading' || theme.status === 'extracting'
? 'disabled:bg-primary disabled:opacity-100'
: ''}"
size="sm"
aria-busy={theme.status === 'downloading' ||
theme.status === 'extracting'}
disabled={isActive ||
theme.status === 'downloading' ||
theme.status === 'extracting' ||
theme.status === 'deleting'}
onclick={() => requestThemeApply(theme)}
>
{#if theme.status === 'downloading' || theme.status === 'extracting'}
<span
class="absolute inset-y-0 left-0 bg-white/20 transition-[width] duration-300 ease-out"
style={`width: ${Math.min(100, Math.max(0, theme.progress * 100))}%`}
></span>
{#if !$reduceAnimations}
<span
class="theme-download-shimmer absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-transparent via-white/25 to-transparent"
></span>
{/if}
{/if}
<span class="relative z-10 flex items-center justify-center gap-2">
{#if theme.status === 'extracting'}
<ThemeIcon
name="loader"
set={iconSet}
class="size-4 animate-spin"
/>
{/if}
<TextMorph
text={themeButtonLabel(
theme,
$active_custom_theme?.folder_name
)}
duration={260}
ease="cubic-bezier(0.19, 1, 0.22, 1)"
disabled={$reduceAnimations}
class="whitespace-nowrap"
/>
</span>
</Button>
{#if theme.status === 'installed' && theme.definition}
<Button
variant="secondary"
size="icon"
class="size-7 shrink-0"
title="Edit theme"
onclick={() => openThemeEditor(theme)}
>
<ThemeIcon name="edit" set={iconSet} class="size-3.5" />
</Button>
{/if}
{#if theme.status === 'installed' && (theme.backups?.length ?? 0) > 0}
<Button
variant="secondary"
size="icon"
class="size-7 shrink-0"
title="Restore previous version ({new Date(
theme.backups![0].createdAt
).toLocaleDateString()})"
onclick={() => requestThemeRestore(theme, theme.backups![0])}
>
<ThemeIcon name="history" set={iconSet} class="size-3.5" />
</Button>
{/if}
{#if theme.status === 'installed' && theme.updateAvailable}
<Button
variant="secondary"
size="icon"
class="size-7 shrink-0"
title={`Update theme${theme.availableVersion ? ` to v${theme.availableVersion}` : ''}`}
onclick={() => updateInstalledTheme(theme)}
>
<ThemeIcon name="download" set={iconSet} class="size-3.5" />
</Button>
{/if}
{#if (theme.status === 'installed' || theme.status === 'deleting') && !isActive && theme.name !== 'Default'}
<Button
variant="destructive"
size="icon"
class="size-7 shrink-0"
title="Uninstall theme"
disabled={theme.status !== 'installed'}
onclick={async () => {
try {
const deleteResult = await deleteTheme(theme);
if (deleteResult) {
sileo.success({
title: 'Yaay!',
description: 'Theme uninstalled successfully!',
});
} else {
sileo.error({
title: 'Uhhm..',
description: 'Failed to uninstall theme.',
});
}
} catch (err) {
console.log(err);
sileo.error({
title: 'Uhhm..',
description: 'Failed to uninstall theme.',
});
}
}}
>
{#if theme.status === 'deleting'}
<ThemeIcon
name="loader"
set={iconSet}
class="size-3.5 animate-spin"
/>
{:else}
<ThemeIcon name="trash" set={iconSet} class="size-3.5" />
{/if}
</Button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/key}
{:else}
<div
class="flex h-full min-h-[320px] flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground"
>
<div class="rounded-full bg-theme-900/60 p-3">
<ThemeIcon name="search" set={iconSet} class="size-5 opacity-60" />
</div>
<p class="text-sm font-semibold text-theme-100">
{#if themeSearch}
No themes match "{themeSearch}"
{:else if themeFilter === 'updates'}
Everything is up to date
{:else}
No themes found
{/if}
</p>
<p class="max-w-xs text-xs leading-relaxed">
{#if themeSearch}
Try a different name, author or API version.
{:else if themeFilter === 'updates'}
You are running the latest version of every installed theme.
{:else}
Discover themes in the Community Workshop or create a new API 2.0 theme.
{/if}
</p>
{#if themeSearch || themeFilter !== 'all'}
<Button
class="mt-1"
variant="secondary"
size="sm"
onclick={() => {
themeSearch = '';
themeFilter = 'all';
}}
>
Clear filters
</Button>
{/if}
</div>
{/if}
</ScrollContainer>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
{#if selectedView === 'HOME' && $active_custom_theme?.definition?.elements?.length}
<div
class="pointer-events-none absolute inset-0 z-20"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
y: 5,
opacity: 0,
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
{#key $theme_reload_token}
<ThemeV2Elements
elements={$active_custom_theme.definition.elements}
groups={$active_custom_theme.definition.groups}
assets={$active_custom_theme.assets}
themeName={$active_custom_theme.definition.name}
themeAuthor={$active_custom_theme.definition.author}
themeVersion={$active_custom_theme.definition.version}
accentColor={$active_custom_theme.definition.accentColor}
{iconSet}
screen={selectedView.toLowerCase()}
visibility={$themeScriptState.visibility}
groupVisibility={$themeScriptState.groupVisibility}
variables={$themeScriptState.variables}
launching={$launching}
launchProgress={progress}
launchText={$launching
? downloadingEZPPFiles
? 'Downloading...'
: cleanup
? 'Cleaning up...'
: 'Launching...'
: $serverConnectionFails > 1
? 'No connection'
: $osuInstallationPath === ''
? 'No osu! path'
: 'Launch'}
launchSubtext={$launching && !cleanup
? launchInfo
: $osuInstallationPath === ''
? 'osu! path is not set!'
: $serverConnectionFails > 1
? 'No connection...'
: undefined}
launchDisabled={$osuInstallationPath === '' || $serverConnectionFails > 1}
oninteraction={triggerThemeScriptElement}
onbuttonaction={handleThemeButtonAction}
/>
{/key}
</div>
{/if}
{#if logViewerOpen}
<LogViewer onclose={() => (logViewerOpen = false)} />
{/if}
<DiscordPresenceEditor bind:open={presenceEditorOpen} />
</div>
<style>
@keyframes theme-download-shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(200%);
}
}
.theme-download-shimmer {
animation: theme-download-shimmer 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.theme-download-shimmer {
animation: none;
}
}
</style>