diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index c31dc4a..f4c8982 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -160,6 +160,7 @@ pub struct WineRuntime { prefix: PathBuf, wine_bin: PathBuf, wine_root: Option, + app_bundle: Option, } #[derive(Clone)] @@ -234,6 +235,7 @@ impl WineRuntime { prefix, wine_bin, wine_root, + app_bundle: app, }) } @@ -260,27 +262,70 @@ impl WineRuntime { command.env("WINEDLLOVERRIDES", "mscoree,mshtml="); command.env("FREETYPE_PROPERTIES", "truetype:interpreter-version=35"); + let mut library_paths = Vec::new(); + + if let Some(app) = &self.app_bundle { + for dir in [ + app.join("Contents/Frameworks"), + app.join("Contents/Frameworks/wswine.bundle/lib"), + app.join("Contents/Frameworks/wswine.bundle/lib64"), + app.join("Contents/Frameworks/wswine.bundle/lib32on64"), + app.join("Contents/SharedSupport/wine/lib"), + app.join("Contents/SharedSupport/wine/lib64"), + app.join("Contents/SharedSupport/wine/lib32on64"), + app.join("Frameworks"), + ] { + if dir.is_dir() { + library_paths.push(dir); + } + } + } + + for dir in [ + self.prefix.join("Frameworks"), + self.prefix.join("lib"), + self.prefix.join("lib64"), + self.prefix.join("lib32on64"), + ] { + if dir.is_dir() { + library_paths.push(dir); + } + } + if let Some(wine_root) = &self.wine_root { - let mut library_paths = vec![ + for dir in [ wine_root.join("lib"), wine_root.join("lib64"), wine_root.join("lib32on64"), wine_root.clone(), - PathBuf::from("/opt/local/lib"), - PathBuf::from("/usr/lib"), - PathBuf::from("/usr/libexec"), - PathBuf::from("/usr/lib/system"), - PathBuf::from("/opt/X11/lib"), - ]; - if let Some(existing) = std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH") { - library_paths.extend(std::env::split_paths(&existing)); + ] { + if dir.is_dir() { + library_paths.push(dir); + } } - if let Ok(library_path) = std::env::join_paths(library_paths) { - command.env("DYLD_FALLBACK_LIBRARY_PATH", &library_path); - command.env("WINESKIN_LIB_PATH_FOR_FALLBACK", &library_path); + } + + for system_dir in [ + PathBuf::from("/opt/local/lib"), + PathBuf::from("/usr/lib"), + PathBuf::from("/usr/libexec"), + PathBuf::from("/usr/lib/system"), + PathBuf::from("/opt/X11/lib"), + ] { + if system_dir.is_dir() { + library_paths.push(system_dir); } } + if let Some(existing) = std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH") { + library_paths.extend(std::env::split_paths(&existing)); + } + + if let Ok(library_path) = std::env::join_paths(library_paths) { + command.env("DYLD_FALLBACK_LIBRARY_PATH", &library_path); + command.env("WINESKIN_LIB_PATH_FOR_FALLBACK", &library_path); + } + let mut paths = vec![self.wine_bin.clone()]; if let Some(existing) = std::env::var_os("PATH") { paths.extend(std::env::split_paths(&existing)); @@ -310,14 +355,23 @@ impl WineRuntime { return; } - let _ = Command::new(wineserver) - .arg("-k") + let mut cmd = Command::new(wineserver); + cmd.arg("-k") .env("WINEPREFIX", &self.prefix) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + .stderr(Stdio::null()); + + if let Some(app) = &self.app_bundle { + if let Ok(fallback) = std::env::join_paths([ + app.join("Contents/Frameworks"), + app.join("Frameworks"), + ]) { + cmd.env("DYLD_FALLBACK_LIBRARY_PATH", fallback); + } + } + + let _ = cmd.status().await; } fn loader_for(&self, exe: &Path) -> PathBuf { @@ -325,7 +379,7 @@ impl WineRuntime { let candidates: &[&str] = if is_64_bit { &["wine64", "wine"] } else { - &["wine32on64", "wine", "wine64"] + &["wine32on64", "wine64", "wine"] }; candidates @@ -783,9 +837,15 @@ fn find_wine_bin(prefix: &Path, app: Option<&Path>) -> Option<(PathBuf, Option

bool { } fn wine_version_for(bin: &Path) -> Option { - let loader = ["wine64", "wine", "wine32on64"] + let loader = ["wine64", "wine32on64", "wine"] .iter() .map(|name| bin.join(name)) .find(|path| path.is_file())?; diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index 36b8fc2..bcbf4cf 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -71,18 +71,26 @@ createDefaultThemeV2, createThemeV2Behavior, createThemeV2Element, + normalizeChrome, normalizeThemeV2, themeAssetName, + themeHasLaunchButton, type ThemeV2Asset, + type ThemeV2Background, type ThemeV2BehaviorTarget, + type ThemeV2ButtonAction, + type ThemeV2Chrome, type ThemeV2Definition, type ThemeV2Element, type ThemeV2Font, type ThemeV2Group, type ThemeV2ImageElement, + type ThemeV2LayoutMode, type ThemeV2LottieElement, type ThemeV2ScriptValue, type ThemeV2VideoElement, + type ThemeV2WidgetElement, + type ThemeV2WidgetKind, } from '$lib/themeV2'; import { exportThemeFolder, @@ -94,7 +102,7 @@ import type { EZPPUser, EZPPUserInfo } from '$lib/types'; import { currentUser } from '$lib/userAuthentication'; import { osuInstallationPath } from '$lib/userSettings'; - import { formatBytes } from '$lib/utils'; + import { formatBytes, openURL } from '$lib/utils'; import { AlertTriangle, Boxes, @@ -105,6 +113,7 @@ ChevronUp, ChevronsDown, ChevronsUp, + CircleUser, ClipboardPaste, Clock, Code2, @@ -121,6 +130,7 @@ Image as ImageIcon, Keyboard, Layers, + LayoutGrid, LoaderCircle, Lock, Maximize2, @@ -230,6 +240,18 @@ let definition = $state(createDefaultThemeV2()); let pickedAssets = $state([]); + let lastImageBackground = $state | undefined>( + undefined + ); + let lastVideoBackground = $state | undefined>( + undefined + ); + let lastColorBackground = $state | undefined>( + undefined + ); + let lastGradientBackground = $state | undefined>( + undefined + ); let assetBase = $state(''); let savedFolder = $state(undefined); let editorFolderPath = $state(undefined); @@ -292,6 +314,46 @@ const elements = $derived(definition.elements ?? []); const groups = $derived(definition.groups ?? []); const behaviors = $derived(definition.behaviors ?? []); + const themeChrome = $derived( + definition.chrome ?? normalizeChrome(undefined, definition.elements) + ); + const hasValidLaunchButton = $derived(themeHasLaunchButton(definition)); + + function updateChrome(key: K, value: ThemeV2Chrome[K]) { + const current = definition.chrome ?? normalizeChrome(undefined, definition.elements); + definition.chrome = { + ...current, + [key]: value, + }; + } + + function setLayoutMode(mode: ThemeV2LayoutMode) { + const current = definition.chrome ?? normalizeChrome(undefined, definition.elements); + if (mode === 'dashboard') { + definition.chrome = { + ...current, + layoutMode: 'dashboard', + playerCard: true, + recentActivity: true, + pills: true, + dock: true, + }; + } else if (mode === 'classic') { + definition.chrome = { + ...current, + layoutMode: 'classic', + playerCard: true, + recentActivity: false, + pills: false, + dock: true, + }; + } else { + definition.chrome = { + ...current, + layoutMode: 'custom', + }; + } + } const logicBadges = $derived.by(() => { const badges = new SvelteMap(); @@ -814,17 +876,24 @@ if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId); } - const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => ({ - ...theme, - typography: { - body: theme.typography?.body ?? 'Sora Variable', - heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable', - ...(theme.typography?.mono ? { mono: theme.typography.mono } : {}), - }, - fonts: theme.fonts ?? [], - elements: theme.elements ?? [], - groups: theme.groups ?? [], - }); + const ensureDefaults = (theme: ThemeV2Definition): ThemeV2Definition => { + if (theme.background.type === 'image' && theme.background.src && !lastImageBackground) { + lastImageBackground = { ...theme.background }; + } else if (theme.background.type === 'video' && theme.background.src && !lastVideoBackground) { + lastVideoBackground = { ...theme.background }; + } + return { + ...theme, + typography: { + body: theme.typography?.body ?? 'Sora Variable', + heading: theme.typography?.heading ?? theme.typography?.body ?? 'Sora Variable', + ...(theme.typography?.mono ? { mono: theme.typography.mono } : {}), + }, + fonts: theme.fonts ?? [], + elements: theme.elements ?? [], + groups: theme.groups ?? [], + }; + }; type EditorSnapshot = { definition: ThemeV2Definition; @@ -1899,43 +1968,81 @@ function setBackgroundType(type: ThemeV2Definition['background']['type']) { const current = definition.background; + + // Cache the active background before switching + if (current.type === 'image' && current.src) { + lastImageBackground = { ...current }; + } else if (current.type === 'video' && current.src) { + lastVideoBackground = { ...current }; + } else if (current.type === 'color') { + lastColorBackground = { ...current }; + } else if (current.type === 'gradient') { + lastGradientBackground = { ...current }; + } + const opacity = current.opacity ?? 1; const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0; const fit = current.type === 'image' || current.type === 'video' ? (current.fit ?? 'cover') : 'cover'; + if (type === 'color') { definition.background = { - type, - color: current.type === 'color' ? current.color : '#181825', - opacity, + type: 'color', + color: current.type === 'color' ? current.color : (lastColorBackground?.color ?? '#181825'), + opacity: lastColorBackground?.opacity ?? opacity, }; } else if (type === 'gradient') { definition.background = { - type, - from: current.type === 'gradient' ? current.from : '#181825', - to: current.type === 'gradient' ? current.to : '#3b1d4f', - angle: current.type === 'gradient' ? (current.angle ?? 135) : 135, - opacity, + type: 'gradient', + from: + current.type === 'gradient' ? current.from : (lastGradientBackground?.from ?? '#181825'), + to: current.type === 'gradient' ? current.to : (lastGradientBackground?.to ?? '#3b1d4f'), + angle: + current.type === 'gradient' + ? (current.angle ?? 135) + : (lastGradientBackground?.angle ?? 135), + opacity: lastGradientBackground?.opacity ?? opacity, }; } else if (type === 'video') { + const candidateVideo = + current.type === 'video' && current.src + ? current.src + : lastVideoBackground?.src || + pickedAssets.find((a) => /\.(mp4|webm|ogv|mov)$/i.test(a.name))?.name || + ''; + definition.background = { - type, - src: current.type === 'video' ? current.src : '', - fit, - opacity, - blur, - muted: current.type === 'video' ? current.muted !== false : true, + type: 'video', + src: candidateVideo, + fit: current.type === 'video' ? fit : (lastVideoBackground?.fit ?? fit), + opacity: current.type === 'video' ? opacity : (lastVideoBackground?.opacity ?? opacity), + blur: current.type === 'video' ? blur : (lastVideoBackground?.blur ?? blur), + muted: + current.type === 'video' ? current.muted !== false : lastVideoBackground?.muted !== false, }; + if (candidateVideo) { + lastVideoBackground = { ...definition.background }; + } + definition.audio = undefined; } else { + const candidateImage = + current.type === 'image' && current.src + ? current.src + : lastImageBackground?.src || + pickedAssets.find((a) => /\.(png|jpg|jpeg|webp|gif|avif)$/i.test(a.name))?.name || + ''; + definition.background = { - type, - src: current.type === 'image' ? current.src : '', - fit, - opacity, - blur, + type: 'image', + src: candidateImage, + fit: current.type === 'image' ? fit : (lastImageBackground?.fit ?? fit), + opacity: current.type === 'image' ? opacity : (lastImageBackground?.opacity ?? opacity), + blur: current.type === 'image' ? blur : (lastImageBackground?.blur ?? blur), }; + if (candidateImage) { + lastImageBackground = { ...definition.background }; + } } - if (type === 'video') definition.audio = undefined; } async function chooseBackground(type: 'image' | 'video') { @@ -1947,7 +2054,7 @@ const opacity = current.opacity ?? 1; const blur = current.type === 'image' || current.type === 'video' ? (current.blur ?? 0) : 0; if (type === 'video') { - definition.background = { + const bg: Extract = { type, src: asset.name, fit, @@ -1955,14 +2062,18 @@ blur, muted: current.type === 'video' ? current.muted !== false : true, }; + definition.background = bg; + lastVideoBackground = { ...bg }; } else { - definition.background = { + const bg: Extract = { type, src: asset.name, fit, opacity, blur, }; + definition.background = bg; + lastImageBackground = { ...bg }; } } @@ -2101,8 +2212,36 @@ | 'particles' | 'clock' | 'countdown' + | 'widget', + widgetKind: ThemeV2WidgetKind = 'launch-button' ) { const element = createThemeV2Element(type, elements.length); + if (element.type === 'widget') { + element.widget = widgetKind; + if (widgetKind === 'player-card') { + element.width = 380; + element.height = 230; + } else if (widgetKind === 'avatar') { + element.width = 72; + element.height = 72; + element.variant = 'circle'; + element.showCountry = true; + element.showLevel = true; + element.showOnlineRing = true; + } else if (widgetKind === 'recent-activity') { + element.width = 340; + element.height = 230; + } else if (widgetKind === 'telemetry') { + element.width = 320; + element.height = 42; + } else if (widgetKind === 'header') { + element.width = 340; + element.height = 64; + } else if (widgetKind === 'launch-button') { + element.width = 210; + element.height = 48; + } + } if (selectedGroupId) element.groupId = selectedGroupId; definition.elements = [...elements, element]; selectedGroupId = undefined; @@ -2110,6 +2249,188 @@ leftTab = 'layers'; } + function handleDetachWidget(widgetKind: ThemeV2WidgetKind) { + const newElements = [...(definition.elements ?? [])]; + const currentChrome = definition.chrome ?? normalizeChrome(undefined, newElements); + + if (widgetKind === 'header') { + let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'header'); + if (!existing) { + const headerWidget = createThemeV2Element( + 'widget', + newElements.length + ) as ThemeV2WidgetElement; + headerWidget.widget = 'header'; + headerWidget.name = 'Header Title'; + headerWidget.x = 88; + headerWidget.y = 28; + headerWidget.width = 320; + headerWidget.height = 64; + newElements.push(headerWidget); + existing = headerWidget; + } + definition.chrome = { + ...currentChrome, + layoutMode: 'custom', + header: false, + }; + definition.elements = newElements; + selectedIds = [existing.id]; + leftTab = 'layers'; + } else if (widgetKind === 'launch-button') { + let existing = newElements.find( + (el) => + (el.type === 'widget' && el.widget === 'launch-button') || + (el.type === 'button' && el.action.type === 'launch') + ); + if (!existing) { + const launchBtn = createThemeV2Element( + 'widget', + newElements.length + ) as ThemeV2WidgetElement; + launchBtn.widget = 'launch-button'; + launchBtn.name = 'Launch Button'; + launchBtn.x = 740; + launchBtn.y = 575; + launchBtn.width = 210; + launchBtn.height = 48; + newElements.push(launchBtn); + existing = launchBtn; + } + definition.chrome = { + ...currentChrome, + layoutMode: 'custom', + dock: false, + }; + definition.elements = newElements; + selectedIds = [existing.id]; + leftTab = 'layers'; + } else if (widgetKind === 'player-card') { + let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'player-card'); + if (!existing) { + const playerCard = createThemeV2Element( + 'widget', + newElements.length + ) as ThemeV2WidgetElement; + playerCard.widget = 'player-card'; + playerCard.name = 'Player Card'; + playerCard.x = 100; + playerCard.y = 150; + playerCard.width = 380; + playerCard.height = 230; + newElements.push(playerCard); + existing = playerCard; + } + definition.chrome = { + ...currentChrome, + layoutMode: 'custom', + playerCard: false, + }; + definition.elements = newElements; + selectedIds = [existing.id]; + leftTab = 'layers'; + } else if (widgetKind === 'avatar') { + let existing = newElements.find((el) => el.type === 'widget' && el.widget === 'avatar'); + if (!existing) { + const avatarWidget = createThemeV2Element( + 'widget', + newElements.length + ) as ThemeV2WidgetElement; + avatarWidget.widget = 'avatar'; + avatarWidget.name = 'Player Avatar'; + avatarWidget.x = 80; + avatarWidget.y = 80; + avatarWidget.width = 72; + avatarWidget.height = 72; + avatarWidget.variant = 'circle'; + avatarWidget.showCountry = true; + avatarWidget.showLevel = true; + avatarWidget.showOnlineRing = true; + newElements.push(avatarWidget); + existing = avatarWidget; + } + definition.chrome = { + ...currentChrome, + layoutMode: 'custom', + }; + definition.elements = newElements; + selectedIds = [existing.id]; + leftTab = 'layers'; + } else if (widgetKind === 'recent-activity') { + let existing = newElements.find( + (el) => el.type === 'widget' && el.widget === 'recent-activity' + ); + if (!existing) { + const recentActivity = createThemeV2Element( + 'widget', + newElements.length + ) as ThemeV2WidgetElement; + recentActivity.widget = 'recent-activity'; + recentActivity.name = 'Recent Activity'; + recentActivity.x = 510; + recentActivity.y = 150; + recentActivity.width = 340; + recentActivity.height = 230; + newElements.push(recentActivity); + existing = recentActivity; + } + definition.chrome = { + ...currentChrome, + layoutMode: 'custom', + recentActivity: false, + }; + definition.elements = newElements; + selectedIds = [existing.id]; + leftTab = 'layers'; + } + } + + function detachAllToCanvas() { + handleDetachWidget('header'); + handleDetachWidget('launch-button'); + handleDetachWidget('player-card'); + handleDetachWidget('recent-activity'); + } + + function restoreNativeDock() { + const currentChrome = definition.chrome ?? normalizeChrome(undefined, definition.elements); + definition.chrome = { + ...currentChrome, + layoutMode: 'dashboard', + dock: true, + playerCard: true, + recentActivity: true, + header: true, + }; + } + + function handleTestButtonAction(action: ThemeV2ButtonAction) { + if (action.type === 'navigate') { + sileo.info({ + title: 'Button Action Test', + description: `Navigates to screen "${action.screen}" in launcher.`, + }); + } else if (action.type === 'launch') { + sileo.info({ + title: 'Button Action Test', + description: 'Launches osu! game.', + }); + } else if (action.type === 'url') { + if (action.url) { + sileo.info({ + title: 'Button Action Test', + description: `Opens ${action.url}`, + }); + void openURL(action.url); + } else { + sileo.warning({ + title: 'Button Action Test', + description: 'No URL specified', + }); + } + } + } + async function addLottie() { const asset = await chooseAsset('lottie'); if (!asset) return; @@ -2677,6 +2998,11 @@ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(definition.version.trim())) { warning('The version should use semantic versioning, for example 1.0.0.'); } + if (!themeHasLaunchButton(definition)) { + error( + 'A launch button is required to launch osu!. Keep the bottom dock visible or add a launch button widget to your canvas.' + ); + } const background = definition.background; if ((background.type === 'image' || background.type === 'video') && !background.src.trim()) { @@ -3276,6 +3602,31 @@ Add countdown + addLayer('widget', 'launch-button')} + > + + Add launch button widget + + addLayer('widget', 'player-card')} + > + + Add player card widget + + addLayer('widget', 'avatar')}> + + Add player avatar widget + + addLayer('widget', 'recent-activity')} + > + + Add recent activity widget + Add Lottie animation @@ -3621,6 +3972,34 @@ Countdown + addLayer('widget', 'launch-button')} + > + + Launch Button Widget + + addLayer('widget', 'player-card')} + > + + Player Card Widget + + addLayer('widget', 'avatar')} + > + + Player Avatar Widget + + addLayer('widget', 'recent-activity')} + > + + Recent Activity Widget + Lottie animation @@ -4500,6 +4879,191 @@ onchange={setVariableDefault} /> + +

+ +
+ {#each [{ value: 'dashboard', label: 'Dashboard' }, { value: 'classic', label: 'Classic' }, { value: 'custom', label: 'Custom' }] as mode (mode.value)} + + {/each} +
+

+ {#if themeChrome.layoutMode === 'dashboard'} + Modern dashboard with large player card and recent activity highlights. + {:else if themeChrome.layoutMode === 'classic'} + Original minimal layout with compact bottom-left stats and open canvas. + {:else} + Custom freeform canvas: default cards are hidden so your layers take center stage. + {/if} +

+
+ + +
+
+ Moveable Canvas Elements + Freeform +
+

+ Convert the Launch button, Player card, and Activity card into draggable layers so + you can position them anywhere. +

+
+ +
+ + + + + + +
+
+
+ +
+ + +
+ + + + + + + + + +
+
+ + +
+
+ {#if hasValidLaunchButton} + + Launch button satisfied + {:else} + + Launch button required! + {/if} +
+

+ {#if hasValidLaunchButton} + Theme includes osu! launch capability ({themeChrome.dock !== false + ? 'via bottom dock' + : 'via canvas launch widget'}). + {:else} + osu! requires a launch button. Turn on the Bottom Dock or add a Launch Button + widget to the canvas. + {/if} +

+
+ +
{#each backgroundTypes as option (option.value)} @@ -4619,6 +5183,11 @@ definition.background.type === 'video' ) { definition.background.blur = value; + if (definition.background.type === 'image' && lastImageBackground) { + lastImageBackground.blur = value; + } else if (definition.background.type === 'video' && lastVideoBackground) { + lastVideoBackground.blur = value; + } } }} /> @@ -4632,7 +5201,13 @@ definition.background.type === 'image' || definition.background.type === 'video' ) { - definition.background.fit = checked ? 'contain' : 'cover'; + const fitVal = checked ? 'contain' : 'cover'; + definition.background.fit = fitVal; + if (definition.background.type === 'image' && lastImageBackground) { + lastImageBackground.fit = fitVal; + } else if (definition.background.type === 'video' && lastVideoBackground) { + lastVideoBackground.fit = fitVal; + } } }} /> @@ -4651,6 +5226,9 @@ onCheckedChange={(checked) => { if (definition.background.type === 'video') { definition.background.muted = checked === true; + if (lastVideoBackground) { + lastVideoBackground.muted = checked === true; + } } }} /> @@ -5157,6 +5735,8 @@ onpatchgroup={(id, patch) => patchGroup(id, patch)} oncontext={(id) => prepareContextMenu(id)} oncontextgroup={(id) => prepareGroupContextMenu(id)} + ondetach={handleDetachWidget} + onbuttonaction={handleTestButtonAction} />
@@ -5605,6 +6185,38 @@ Lottie + + + + {:else if element.type === 'countdown'} + {:else if element.type === 'widget'} + {#if element.widget === 'avatar'} + + {:else} + + {/if} {:else} {/if} @@ -1593,6 +1606,46 @@

{/if} + +
+ +

+ In editor mode, canvas clicks select layers for dragging. Test actions using this button, or Shift+click on canvas. +

+
@@ -2165,6 +2218,296 @@
+ {:else if element.type === 'widget'} + +
+ + { + if (val) { + element.widget = val as ThemeV2WidgetKind; + if (val === 'player-card') { + element.width = 380; + element.height = 180; + } else if (val === 'avatar') { + element.width = 72; + element.height = 72; + if (!element.variant) element.variant = 'circle'; + } else if (val === 'recent-activity') { + element.width = 320; + element.height = 180; + } else if (val === 'telemetry') { + element.width = 320; + element.height = 42; + } else if (val === 'header') { + element.width = 320; + element.height = 64; + } else if (val === 'launch-button') { + element.width = 200; + element.height = 52; + } + } + }} + > + + {element.widget === 'launch-button' + ? 'Launch Button' + : element.widget === 'player-card' + ? 'Player Card' + : element.widget === 'avatar' + ? 'Player Avatar' + : element.widget === 'recent-activity' + ? 'Recent Activity' + : element.widget === 'telemetry' + ? 'Telemetry Bar' + : 'Header Title'} + + + + + + + + + + +
+ +
+ + { + if (val) { + element.variant = val as ThemeV2WidgetVariant; + if (element.widget === 'player-card') { + if (val === 'minimal') { + element.width = 280; + element.height = 64; + } else if (val === 'compact') { + element.width = 340; + element.height = 120; + } else if (val === 'full') { + element.width = 380; + element.height = 200; + } + } else if (element.widget === 'recent-activity') { + if (val === 'minimal') { + element.width = 280; + element.height = 64; + } else if (val === 'compact') { + element.width = 340; + element.height = 120; + } else if (val === 'full') { + element.width = 340; + element.height = 200; + } + } else if (element.widget === 'launch-button') { + if (val === 'icon-only') { + element.width = 48; + element.height = 48; + } else if (val === 'compact') { + element.width = 140; + element.height = 42; + } else if (val === 'primary') { + element.width = 210; + element.height = 48; + } + } else if (element.widget === 'header') { + if (val === 'minimal') { + element.width = 240; + element.height = 42; + } else if (val === 'compact') { + element.width = 380; + element.height = 48; + } else if (val === 'full') { + element.width = 320; + element.height = 64; + } + } + } + }} + > + + {#if element.widget === 'avatar'} + {element.variant === 'rounded' + ? 'Rounded Square' + : element.variant === 'square' + ? 'Square' + : 'Circle'} + {:else if element.widget === 'launch-button'} + {element.variant === 'compact' + ? 'Compact (Pill)' + : element.variant === 'icon-only' + ? 'Icon-Only (Circle)' + : 'Primary (Capsule)'} + {:else} + {element.variant === 'compact' + ? 'Compact' + : element.variant === 'minimal' || element.variant === 'icon-only' + ? 'Minimal' + : 'Full'} + {/if} + + + {#if element.widget === 'avatar'} + + + + {:else if element.widget === 'launch-button'} + + + + {:else} + + + + {/if} + + +
+ + {#if element.widget === 'avatar'} +
+ +
+
+ (element.showCountry = checked === true)} + /> + +
+
+ (element.showLevel = checked === true)} + /> + +
+
+ (element.showOnlineRing = checked === true)} + /> + +
+
+
+ {/if} + + +
+ +
+ + + + + +
+
+ +

+ {#if element.widget === 'launch-button'} + Interactive game launch button. Satisfies the required Launch button condition. + {:else if element.widget === 'player-card'} + Live stats card displaying rank, pp, accuracy, level, and playcount. + {:else if element.widget === 'avatar'} + Player avatar with live profile image, shape styles, country badge, and online ring. + {:else if element.widget === 'recent-activity'} + Highlights the player's latest play, beatmap cover art, and grade badge. + {:else if element.widget === 'telemetry'} + Pill bar showing beatmap counts, skin counts, ping, and osu! build. + {:else if element.widget === 'header'} + Launcher title and dynamic greeting. Full shows title and subtitle, Compact shows an inline pill greeting, and Minimal displays title only. + {:else} + Launcher title and dynamic greeting. + {/if} +

+
{/if} diff --git a/src/lib/components/theme/ThemeStage.svelte b/src/lib/components/theme/ThemeStage.svelte index 1c551e5..5dbb94f 100644 --- a/src/lib/components/theme/ThemeStage.svelte +++ b/src/lib/components/theme/ThemeStage.svelte @@ -3,16 +3,25 @@ import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte'; import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte'; + import * as Avatar from '$lib/components/ui/avatar'; + import { currentUserInfo } from '$lib/data'; import { scopeThemeCss } from '$lib/themeCssScope'; import { fontStack, monoFontStack } from '$lib/themeFonts'; import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons'; import { + normalizeChrome, readableTextColor, themeFontFaceCss, + themeHasLaunchButton, + type ThemeV2ButtonAction, type ThemeV2Definition, type ThemeV2Group, type ThemeV2ScriptValue, + type ThemeV2WidgetKind, } from '$lib/themeV2'; + import { currentUser } from '$lib/userAuthentication'; + import { countryCodeToEmoji } from '$lib/utils'; + import { Clock, Trophy } from '@lucide/svelte'; let { definition, @@ -31,6 +40,8 @@ onpatchgroup, oncontext, oncontextgroup, + ondetach, + onbuttonaction, }: { definition: ThemeV2Definition; assets?: string; @@ -48,6 +59,8 @@ onpatchgroup?: (id: string, patch: Partial) => void; oncontext?: (id: string | undefined, event: MouseEvent) => void; oncontextgroup?: (id: string, event: MouseEvent) => void; + ondetach?: (widgetKind: ThemeV2WidgetKind) => void; + onbuttonaction?: (action: ThemeV2ButtonAction) => void; } = $props(); const bodyFont = $derived(definition.typography?.body); @@ -56,6 +69,8 @@ const accent = $derived(definition.accentColor?.trim() || '#ff006e'); const accentText = $derived(readableTextColor(accent)); const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET); + const chrome = $derived(definition.chrome ?? normalizeChrome(undefined, definition.elements)); + const hasLaunchBtn = $derived(themeHasLaunchButton(definition)); const stageStyle = $derived( [ `font-family:${fontStack(bodyFont)}`, @@ -124,94 +139,283 @@ -
-
-
- - EZPPLauncher - - Hello Guest! -
- -
+
+ {#if chrome.header !== false} +
+
{ + if (interactive) { + e.stopPropagation(); + ondetach?.('header'); + } + }} + title={interactive ? 'Click to detach into moveable Header Title layer' : undefined} > -
- See your stats here - - Log in with your EZPPFarm account to track your rank, PP, accuracy and playcount. - -
-
+ + EZPPLauncher + + + Good evening, Guest! + + {#if interactive}
- Log In + Click to detach & move freely
-
- Register -
-
+ {/if}
+ {:else} +
+ {/if} + +
+ {#if chrome.layoutMode === 'dashboard'} +
+ {#if chrome.playerCard !== false} + +
{ + if (interactive) { + e.stopPropagation(); + ondetach?.('player-card'); + } + }} + title={interactive ? 'Click to detach into moveable Player Card layer' : undefined} + > +
+
+ + + + + + + {#if $currentUserInfo?.info?.country} + + {countryCodeToEmoji($currentUserInfo.info.country)} + + {/if} +
+
+ {$currentUser?.name ?? 'Guest Player'} + + Lv.{$currentUserInfo?.stats?.['0']?.level ?? 98} · {( + $currentUserInfo?.stats?.['0']?.acc ?? 93 + ).toFixed(1)}% + +
+
+
+
+ Rank + #102 +
+
+ Performance + 4,521pp +
+
+
+ {/if} + + {#if chrome.recentActivity !== false} + +
{ + if (interactive) { + e.stopPropagation(); + ondetach?.('recent-activity'); + } + }} + title={interactive + ? 'Click to detach into moveable Recent Activity layer' + : undefined} + > + +
+
+
+ + Recent Activity + + + S + +
+
+ Space Pirates + Waterflame +
+
+ + +
+
+ + [Galactic Thieves] + +
+ + Rank #1 +
+
+
+ + + 5d ago + + ez-pp.farm +
+
+
+ {/if} +
+ {:else if chrome.layoutMode === 'classic'} + {#if chrome.playerCard !== false} +
+
+
+ See your stats here + Log in with your EZPPFarm account to track your rank. +
+
+
+ Log In +
+
+
+
+ {/if} + {/if}
-
-
-
- - 1.2k -
-
- - 42 -
-
- - 19ms -
-
- - stable - - - 20260101.0 - - - Default skin - -
-
-
-
- + {#if chrome.dock !== false} +
+ {#if chrome.dockTelemetry !== false} +
+
+ + 1.2k +
+
+ + 42 +
+
+ + 19ms +
-
-
-

Launch

+ {:else} +
+ {/if} + + +
{ + if (interactive) { + e.stopPropagation(); + ondetach?.('launch-button'); + } + }} + title={interactive + ? 'Click to detach and position Launch Button freely anywhere' + : undefined} + > +
+
+ +
+
+
+

Launch

+
+ {#if interactive} +
+ Click to detach & move freely +
+ {/if}
-
+ {:else if !hasLaunchBtn} +
+ ⚠️ Launch Button Missing +
+ {/if}
@@ -238,5 +442,6 @@ {onpatchgroup} {oncontext} {oncontextgroup} + {onbuttonaction} />
diff --git a/src/lib/components/theme/ThemeV2Elements.svelte b/src/lib/components/theme/ThemeV2Elements.svelte index b12e687..dfebfb4 100644 --- a/src/lib/components/theme/ThemeV2Elements.svelte +++ b/src/lib/components/theme/ThemeV2Elements.svelte @@ -2,6 +2,9 @@ import ThemeCustomLayer from '$lib/components/theme/ThemeCustomLayer.svelte'; import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte'; import ThemeLottieLayer from '$lib/components/theme/ThemeLottieLayer.svelte'; + import * as Avatar from '$lib/components/ui/avatar'; + import { currentUserInfo } from '$lib/data'; + import { launching as launchingStore } from '$lib/global'; import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData'; import { fontStack } from '$lib/themeFonts'; import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons'; @@ -39,7 +42,19 @@ type ThemeV2Point, type ThemeV2ScriptValue, } from '$lib/themeV2'; - import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte'; + import { currentUser } from '$lib/userAuthentication'; + import { countryCodeToEmoji, formatRelativeTime, openURL } from '$lib/utils'; + import { + Boxes, + ChevronRight, + Clock, + Medal, + Move, + RotateCw, + TrendingDown, + TrendingUp, + Trophy, + } from '@lucide/svelte'; import { onDestroy } from 'svelte'; import { SvelteSet } from 'svelte/reactivity'; import { TextMorph } from 'torph/svelte'; @@ -62,6 +77,10 @@ visibility = {}, groupVisibility = {}, variables = {}, + launching = undefined, + launchProgress = -1, + launchText = 'Launch', + launchDisabled = false, oninteraction, onselect, onselectmany, @@ -91,6 +110,10 @@ groupVisibility?: Record; /** Rule variables available to `{name}` placeholders in text layers. */ variables?: Record; + launching?: boolean; + launchProgress?: number; + launchText?: string; + launchDisabled?: boolean; oninteraction?: (id: string, type: 'click' | 'hover' | 'leave') => void; onselect?: (id: string | undefined, additive?: boolean) => void; onselectmany?: (ids: string[], additive?: boolean) => void; @@ -103,6 +126,8 @@ onbuttonaction?: (action: ThemeV2ButtonAction) => void; } = $props(); + const isLaunching = $derived(launching !== undefined ? launching : $launchingStore); + const assetUrl = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path); const ancestorsOf = (element: ThemeV2Element) => themeGroupAncestors(groups, element); @@ -138,10 +163,18 @@ (element) => (element.type === 'data' && element.field.startsWith('clock.')) || element.type === 'clock' || - element.type === 'countdown' + element.type === 'countdown' || + (element.type === 'widget' && element.widget === 'header') ) ); + const timeGreeting = $derived.by(() => { + const hour = clockNow.getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; + }); + $effect(() => { if (!usesClock) return; const timer = window.setInterval(() => (clockNow = new Date()), 1000); @@ -1560,9 +1593,13 @@ /> {/key} {:else if element.type === 'button'} + {@const isActionLaunch = element.action?.type === 'launch'} + {:else if element.variant === 'compact'} + + {:else} + + {/if} + {:else if element.widget === 'player-card'} + {@const playerStats = + $currentUserInfo?.stats?.['0'] ?? $themeGlobalData?.user?.stats?.osu} + {@const accuracyVal = + playerStats && 'acc' in playerStats + ? playerStats.acc + : playerStats && 'accuracy' in playerStats + ? playerStats.accuracy + : 98.3} + {@const playerId = $currentUser?.id ?? $currentUserInfo?.info?.id ?? 0} + {@const playerName = $currentUser?.name ?? 'Player'} + {@const country = $currentUserInfo?.info?.country} + {@const levelVal = playerStats && 'level' in playerStats ? playerStats.level : 1} + {@const levelProgress = + playerStats && 'level_progress' in playerStats ? playerStats.level_progress : 75} + {@const rankVal = playerStats?.rank ? playerStats.rank : 102} + {@const countryRankVal = + playerStats && 'country_rank' in playerStats ? playerStats.country_rank : undefined} + {@const ppVal = playerStats?.pp ? playerStats.pp : 4521} + {@const playsVal = playerStats?.plays + ? Number(playerStats.plays).toLocaleString() + : '11,813'} + + {#if element.variant === 'minimal'} +
+
+ + + + + + + {#if country} + + {countryCodeToEmoji(country)} + + {/if} +
+
+ {playerName} + + #{rankVal} · {ppVal}pp + +
+ + Lv.{levelVal} + +
+ {:else if element.variant === 'compact'} +
+
+
+ + + + + + + {#if country} + + {countryCodeToEmoji(country)} + + {/if} +
+
+
+ {playerName} + + Lv.{levelVal} + +
+ + #{rankVal} · {ppVal}pp + +
+
+
+
+ Accuracy + {accuracyVal.toFixed(1)}% +
+
+ Plays + {playsVal} +
+
+
+ {:else} +
+
+
+
+ + + + + + + {#if country} + + {countryCodeToEmoji(country)} + + {/if} +
+ +
+ {playerName} +
+ + Lv.{levelVal} + +
+
+
+ + {levelProgress}% + +
+
+
+
+ +
+
+
+ Rank + {#if countryRankVal} + #{countryRankVal} + {/if} +
+ #{rankVal} +
+ +
+ Performance + {ppVal}pp +
+ +
+ Accuracy + {accuracyVal.toFixed(1)}% +
+ +
+ Plays + {playsVal} +
+
+
+ {/if} + {:else if element.widget === 'recent-activity'} + {@const event = $currentUserInfo?.events?.[0]} + {#if element.variant === 'minimal'} +
+ {#if event?.setId} + {event.title} + {:else} +
+ +
+ {/if} +
+ {event?.title ?? 'Space Pirates'} + + {event?.artist ?? 'Waterflame'} · {event + ? formatRelativeTime(event.time) + : '5d ago'} + +
+ + {event?.grade ?? 'S'} + +
+ {:else if element.variant === 'compact'} +
+
+ {#if event?.setId} + {event.title} + {/if} +
+
+ Recent Activity + + {event?.grade ?? 'S'} + +
+
+ {event?.title ?? 'Space Pirates'} +
+
+
+ [{event?.version ?? 'Galactic Thieves'}] + Rank #{event?.rank ?? 1} +
+
+ {:else} +
+ +
+ {#if event?.setId} + {event.title} + {/if} +
+
+ + Recent Activity + + + {event?.grade ?? 'S'} + +
+
+ {event?.title ?? 'Space Pirates'} + {event?.artist ?? 'Waterflame'} +
+
+ + +
+
+ + [{event?.version ?? 'Galactic Thieves'}] + +
+ + Rank #{event?.rank ?? 1} +
+
+
+ + + {event ? formatRelativeTime(event.time) : '5d ago'} + + ez-pp.farm +
+
+
+ {/if} + {:else if element.widget === 'telemetry'} +
+
+ + {$themeGlobalData?.server?.ping ?? 19}ms +
+ + {$themeGlobalData?.osu?.stream ?? 'stable'} + +
+ {:else if element.widget === 'header'} + {#if element.variant === 'minimal'} +
+ + EZPPLauncher + +
+ {:else if element.variant === 'compact'} +
+ + EZPPLauncher + + + {timeGreeting}, + {$currentUser?.name ?? 'Guest'}! + +
+ {:else} +
+ + EZPPLauncher + + + {timeGreeting}, + {$currentUser?.name ?? 'Guest'}! + +
+ {/if} + {:else if element.widget === 'avatar'} + {@const shapeClass = + element.variant === 'rounded' + ? 'rounded-2xl' + : element.variant === 'square' + ? 'rounded-md' + : 'rounded-full'} + {@const playerId = $currentUser?.id ?? $currentUserInfo?.info?.id ?? 0} + {@const playerName = $currentUser?.name ?? 'Player'} + {@const country = $currentUserInfo?.info?.country} + {@const level = $currentUserInfo?.stats?.['0']?.level} +
{ + if (!interactive && playerId) { + e.stopPropagation(); + void openURL(`https://ez-pp.farm/u/${playerId}`); + } + }} + onkeydown={(e) => { + if (!interactive && playerId && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + e.stopPropagation(); + void openURL(`https://ez-pp.farm/u/${playerId}`); + } + }} + title={playerName} + > + + {#if element.showOnlineRing !== false} +
+ {/if} + + + + + + + + + + + {#if element.showCountry !== false && country} + + {countryCodeToEmoji(country)} + + {/if} + + + {#if element.showLevel && level !== undefined} + + Lv.{level} + + {/if} +
+ {/if} + {/if} diff --git a/src/lib/themeLogic.ts b/src/lib/themeLogic.ts index 67a9b92..74c0c38 100644 --- a/src/lib/themeLogic.ts +++ b/src/lib/themeLogic.ts @@ -26,6 +26,24 @@ export const themeElementLabel = (element: ThemeV2Element): string => { if (element.type === 'countdown') return element.target ? `Countdown · ${element.target}` : 'Countdown'; if (element.type === 'svelte') return 'Svelte component'; + if (element.type === 'widget') { + switch (element.widget) { + case 'launch-button': + return 'Launch button widget'; + case 'player-card': + return 'Player card widget'; + case 'recent-activity': + return 'Recent activity widget'; + case 'telemetry': + return 'Telemetry bar widget'; + case 'header': + return 'Header widget'; + case 'avatar': + return 'Player avatar widget'; + default: + return 'Widget'; + } + } return element.src.split('/').pop() || element.type; }; diff --git a/src/lib/themePreview.ts b/src/lib/themePreview.ts index 372b2eb..2c9c3df 100644 --- a/src/lib/themePreview.ts +++ b/src/lib/themePreview.ts @@ -1,4 +1,9 @@ -import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from './themeData'; +import { + formatInteger, + getThemeDataSampleValue, + resolveThemeDataValue, + themeGlobalData, +} from './themeData'; import { getThemeIconBody } from './themeIconCatalog'; import { THEME_ICON_BODIES } from './themeIconData'; import { @@ -12,6 +17,7 @@ import { import { themeScriptInitialVariables } from './themeScript'; import { interpolateThemeText } from './themeTextVariables'; import { + normalizeChrome, readableTextColor, themeElementTransformMatrix, themeFilterCss, @@ -21,6 +27,7 @@ import { themeMaskPolygonPoints, themeTransformPoint, type ThemeV2ButtonElement, + type ThemeV2Chrome, type ThemeV2ClockElement, type ThemeV2CountdownElement, type ThemeV2DataElement, @@ -37,6 +44,7 @@ import { type ThemeV2ShapeElement, type ThemeV2TextElement, type ThemeV2TickerElement, + type ThemeV2WidgetElement, } from './themeV2'; import lottie from 'lottie-web'; import { get } from 'svelte/store'; @@ -1114,7 +1122,7 @@ const drawPill = ( return width; }; -const drawFooter = (context: PreviewContext) => { +const drawFooter = (context: PreviewContext, chrome?: ThemeV2Chrome) => { const { ctx, definition } = context; const bodyFont = definition.typography?.body || DEFAULT_BODY_FONT; const accent = definition.accentColor ?? DEFAULT_ACCENT; @@ -1132,52 +1140,54 @@ const drawFooter = (context: PreviewContext) => { ctx.lineTo(WIDTH, top + 0.5); ctx.stroke(); - const stats = [ - { icon: 'music' as const, color: '#60a5fa', value: '1.2k' }, - { icon: 'brush' as const, color: '#fbbf24', value: '42' }, - { icon: 'wifi' as const, color: '#34d399', value: '19ms' }, - ]; + if (chrome?.dockTelemetry !== false) { + const stats = [ + { icon: 'music' as const, color: '#60a5fa', value: '1.2k' }, + { icon: 'brush' as const, color: '#fbbf24', value: '42' }, + { icon: 'wifi' as const, color: '#34d399', value: '19ms' }, + ]; - ctx.save(); - ctx.textBaseline = 'middle'; - ctx.textAlign = 'left'; - for (const stat of stats) { - drawIcon(context, stat.icon, x + 7, centerY, 14, stat.color, 1.4); - ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.font = `500 11px ${quoteFamily(bodyFont)}, ui-sans-serif, sans-serif`; - ctx.fillText(stat.value, x + 20, centerY); - x += 20 + ctx.measureText(stat.value).width + 16; + ctx.save(); + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + for (const stat of stats) { + drawIcon(context, stat.icon, x + 7, centerY, 14, stat.color, 1.4); + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.font = `500 11px ${quoteFamily(bodyFont)}, ui-sans-serif, sans-serif`; + ctx.fillText(stat.value, x + 20, centerY); + x += 20 + ctx.measureText(stat.value).width + 16; + } + ctx.restore(); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; + ctx.beginPath(); + ctx.moveTo(x + 0.5, centerY - 7); + ctx.lineTo(x + 0.5, centerY + 7); + ctx.stroke(); + x += 16; + + x += + drawPill( + context, + x, + centerY, + 'stable', + 'rgba(255, 255, 255, 0.5)', + 'rgba(255, 255, 255, 0.1)', + 'rgba(255, 255, 255, 0.06)' + ) + 16; + x += + drawPill( + context, + x, + centerY, + '20260101.0', + 'rgba(255, 255, 255, 0.5)', + 'rgba(255, 255, 255, 0.1)', + 'rgba(255, 255, 255, 0.06)' + ) + 16; + drawPill(context, x, centerY, 'Default skin', accent, rgba(accent, 0.2), rgba(accent, 0.25)); } - ctx.restore(); - - ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; - ctx.beginPath(); - ctx.moveTo(x + 0.5, centerY - 7); - ctx.lineTo(x + 0.5, centerY + 7); - ctx.stroke(); - x += 16; - - x += - drawPill( - context, - x, - centerY, - 'stable', - 'rgba(255, 255, 255, 0.5)', - 'rgba(255, 255, 255, 0.1)', - 'rgba(255, 255, 255, 0.06)' - ) + 16; - x += - drawPill( - context, - x, - centerY, - '20260101.0', - 'rgba(255, 255, 255, 0.5)', - 'rgba(255, 255, 255, 0.1)', - 'rgba(255, 255, 255, 0.06)' - ) + 16; - drawPill(context, x, centerY, 'Default skin', accent, rgba(accent, 0.2), rgba(accent, 0.25)); const buttonWidth = 256; const buttonHeight = 48; @@ -1205,6 +1215,851 @@ const drawFooter = (context: PreviewContext) => { ctx.restore(); }; +const getWidgetPlayerData = (context: PreviewContext) => { + const globalData = get(themeGlobalData); + const name = globalData?.user?.name || getThemeDataSampleValue('user.name') || 'Player'; + const rank = globalData?.user?.stats?.osu?.rank + ? `#${formatInteger(globalData.user.stats.osu.rank)}` + : getThemeDataSampleValue('user.rank'); + const pp = globalData?.user?.stats?.osu?.pp + ? `${formatInteger(globalData.user.stats.osu.pp)}pp` + : getThemeDataSampleValue('user.pp'); + const acc = globalData?.user?.stats?.osu?.accuracy + ? `${globalData.user.stats.osu.accuracy.toFixed(1)}%` + : getThemeDataSampleValue('user.accuracy'); + const plays = globalData?.user?.stats?.osu?.plays + ? formatInteger(globalData.user.stats.osu.plays) + : getThemeDataSampleValue('user.plays'); + const level = 75; + const levelProgress = 75; + const country = globalData?.user?.country || 'US'; + return { name, rank, pp, acc, plays, level, levelProgress, country }; +}; + +const drawWidgetElement = async (context: PreviewContext, element: ThemeV2WidgetElement) => { + const { ctx, definition } = context; + const bodyFont = definition.typography?.body || DEFAULT_BODY_FONT; + const headingFont = definition.typography?.heading || bodyFont; + const accent = definition.accentColor ?? DEFAULT_ACCENT; + const accentText = readableTextColor(accent); + + if (element.widget === 'launch-button') { + const isIconOnly = element.variant === 'icon-only' || element.variant === 'minimal'; + const isCompact = element.variant === 'compact'; + + if (isIconOnly) { + const radius = Math.min(element.width, element.height) / 2; + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + + ctx.save(); + ctx.shadowColor = rgba(accent, 0.35); + ctx.shadowBlur = 14; + fillRoundedRect(ctx, element.x, element.y, element.width, element.height, radius, accent); + ctx.restore(); + + const iconSize = Math.min(element.width, element.height) * 0.42; + drawIcon(context, 'play', centerX + 1, centerY, iconSize, accentText, 2); + } else if (isCompact) { + const radius = element.height / 2; + const centerY = element.y + element.height / 2; + + ctx.save(); + ctx.shadowColor = rgba(accent, 0.3); + ctx.shadowBlur = 12; + fillRoundedRect(ctx, element.x, element.y, element.width, element.height, radius, accent); + ctx.restore(); + + const iconSize = Math.min(14, element.height * 0.35); + ctx.save(); + ctx.font = canvasFont(context, bodyFont, Math.min(12, element.height * 0.32), 700); + const text = 'Launch'; + const textWidth = ctx.measureText(text).width; + const spacing = 7; + const totalW = iconSize + spacing + textWidth; + const startX = element.x + (element.width - totalW) / 2; + + drawIcon(context, 'play', startX + iconSize / 2, centerY, iconSize, accentText, 1.8); + + ctx.fillStyle = accentText; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, startX + iconSize + spacing, centerY); + ctx.restore(); + } else { + // primary + const radius = element.height / 2; + const centerY = element.y + element.height / 2; + + ctx.save(); + ctx.shadowColor = rgba(accent, 0.35); + ctx.shadowBlur = 18; + fillRoundedRect(ctx, element.x, element.y, element.width, element.height, radius, accent); + ctx.restore(); + + const circleR = Math.max(12, element.height * 0.32); + const circleX = element.x + circleR + 10; + ctx.beginPath(); + ctx.arc(circleX, centerY, circleR, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0, 0, 0, 0.22)'; + ctx.fill(); + + drawIcon(context, 'play', circleX + 1, centerY, circleR * 1.05, accentText, 1.8); + + ctx.save(); + ctx.fillStyle = accentText; + ctx.font = canvasFont(context, bodyFont, Math.min(14, element.height * 0.33), 700); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const textCenterX = circleX + circleR + (element.x + element.width - (circleX + circleR)) / 2; + ctx.fillText('Launch', textCenterX, centerY); + ctx.restore(); + } + } else if (element.widget === 'header') { + const { name } = getWidgetPlayerData(context); + const hour = new Date().getHours(); + const timeGreeting = + hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; + const greeting = `${timeGreeting}, ${name}!`; + + ctx.save(); + ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + ctx.shadowBlur = 8; + ctx.shadowOffsetY = 2; + + if (element.variant === 'minimal') { + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, headingFont, Math.min(26, element.height * 0.65), 800); + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText('EZPPLauncher', element.x, element.y + element.height / 2); + } else if (element.variant === 'compact') { + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, headingFont, Math.min(24, element.height * 0.55), 800); + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const centerY = element.y + element.height / 2; + ctx.fillText('EZPPLauncher', element.x, centerY); + const titleWidth = ctx.measureText('EZPPLauncher').width; + + ctx.shadowColor = 'transparent'; + drawPill( + context, + element.x + titleWidth + 14, + centerY, + greeting, + 'rgba(255, 255, 255, 0.9)', + 'rgba(0, 0, 0, 0.45)', + 'rgba(255, 255, 255, 0.12)' + ); + } else { + // full + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, headingFont, Math.min(30, element.height * 0.52), 800); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('EZPPLauncher', element.x, element.y + 4); + + ctx.shadowColor = 'transparent'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.68)'; + ctx.font = canvasFont(context, bodyFont, Math.min(13, element.height * 0.28), 500); + ctx.fillText(greeting, element.x, element.y + element.height - 20); + } + ctx.restore(); + } else if (element.widget === 'avatar') { + const { country } = getWidgetPlayerData(context); + const radius = + element.variant === 'rounded' + ? 16 + : element.variant === 'square' + ? 8 + : Math.min(element.width, element.height) / 2; + + if (element.showOnlineRing !== false) { + ctx.save(); + strokeRoundedRect( + ctx, + element.x - 2, + element.y - 2, + element.width + 4, + element.height + 4, + radius + 2, + rgba(accent, 0.55), + 2 + ); + ctx.restore(); + } + + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + radius, + 'rgba(15, 15, 22, 0.9)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + radius, + 'rgba(255, 255, 255, 0.15)', + 1 + ); + + const iconSize = Math.min(element.width, element.height) * 0.48; + drawIcon( + context, + 'user', + element.x + element.width / 2, + element.y + element.height / 2, + iconSize, + accent, + 1.8 + ); + + if (element.showCountry !== false && country) { + const badgeW = 20; + const badgeH = 14; + const badgeX = element.x + element.width - badgeW + 2; + const badgeY = element.y + element.height - badgeH + 2; + fillRoundedRect(ctx, badgeX, badgeY, badgeW, badgeH, 4, 'rgba(0, 0, 0, 0.85)'); + strokeRoundedRect(ctx, badgeX, badgeY, badgeW, badgeH, 4, 'rgba(255, 255, 255, 0.25)', 1); + ctx.save(); + ctx.fillStyle = '#ffffff'; + ctx.font = '700 8px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(country.toUpperCase().slice(0, 2), badgeX + badgeW / 2, badgeY + badgeH / 2); + ctx.restore(); + } + } else if (element.widget === 'telemetry') { + const radius = element.height / 2; + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + radius, + 'rgba(0, 0, 0, 0.45)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + radius, + 'rgba(255, 255, 255, 0.12)', + 1 + ); + + const centerY = element.y + element.height / 2; + let cx = element.x + 16; + + drawIcon(context, 'wifi', cx, centerY, 13, '#34d399', 1.5); + cx += 12; + + ctx.save(); + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.font = `600 11px ${quoteFamily(bodyFont)}, sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText('19ms', cx, centerY); + cx += ctx.measureText('19ms').width + 12; + + drawPill( + context, + cx, + centerY, + 'stable', + 'rgba(255, 255, 255, 0.6)', + 'rgba(255, 255, 255, 0.1)', + 'rgba(255, 255, 255, 0.08)' + ); + ctx.restore(); + } else if (element.widget === 'player-card') { + const { name, rank, pp, level, levelProgress, acc, plays } = getWidgetPlayerData(context); + + if (element.variant === 'minimal') { + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(0, 0, 0, 0.55)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(255, 255, 255, 0.1)', + 1 + ); + + const centerY = element.y + element.height / 2; + const avatarSize = Math.min(38, element.height - 14); + const avatarX = element.x + 10; + const avatarY = centerY - avatarSize / 2; + + fillRoundedRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 10, 'rgba(20, 20, 28, 0.9)'); + strokeRoundedRect( + ctx, + avatarX, + avatarY, + avatarSize, + avatarSize, + 10, + 'rgba(255, 255, 255, 0.15)', + 1 + ); + drawIcon( + context, + 'user', + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize * 0.48, + accent, + 1.5 + ); + + const textX = avatarX + avatarSize + 10; + ctx.save(); + ctx.textAlign = 'left'; + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 12, 700); + ctx.textBaseline = 'bottom'; + ctx.fillText(name, textX, centerY - 1); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.55)'; + ctx.font = canvasFont(context, bodyFont, 10, 500); + ctx.textBaseline = 'top'; + ctx.fillText(`${rank} · ${pp}`, textX, centerY + 1); + + const badgeW = 38; + const badgeH = 18; + const badgeX = element.x + element.width - badgeW - 10; + fillRoundedRect(ctx, badgeX, centerY - badgeH / 2, badgeW, badgeH, 5, rgba(accent, 0.2)); + ctx.fillStyle = accent; + ctx.font = canvasFont(context, bodyFont, 9, 700); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`Lv.${level}`, badgeX + badgeW / 2, centerY); + ctx.restore(); + } else if (element.variant === 'compact') { + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(0, 0, 0, 0.55)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(255, 255, 255, 0.1)', + 1 + ); + + const avatarSize = 42; + const avatarX = element.x + 14; + const avatarY = element.y + 14; + fillRoundedRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 12, 'rgba(20, 20, 28, 0.9)'); + strokeRoundedRect( + ctx, + avatarX, + avatarY, + avatarSize, + avatarSize, + 12, + 'rgba(255, 255, 255, 0.15)', + 1 + ); + drawIcon( + context, + 'user', + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize * 0.48, + accent, + 1.5 + ); + + const textX = avatarX + avatarSize + 12; + ctx.save(); + ctx.textAlign = 'left'; + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 13, 700); + ctx.textBaseline = 'top'; + ctx.fillText(name, textX, avatarY + 2); + const nameW = ctx.measureText(name).width; + + fillRoundedRect(ctx, textX + nameW + 8, avatarY + 1, 36, 17, 5, rgba(accent, 0.2)); + ctx.fillStyle = accent; + ctx.font = canvasFont(context, bodyFont, 9, 700); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`Lv.${level}`, textX + nameW + 8 + 18, avatarY + 9.5); + + ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.55)'; + ctx.font = canvasFont(context, bodyFont, 10, 500); + ctx.fillText(`${rank} · ${pp}`, textX, avatarY + 24); + + const bottomY = element.y + element.height - 38; + const cellW = (element.width - 36) / 2; + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.beginPath(); + ctx.moveTo(element.x + 14, bottomY - 8); + ctx.lineTo(element.x + element.width - 14, bottomY - 8); + ctx.stroke(); + + fillRoundedRect(ctx, element.x + 14, bottomY, cellW, 28, 8, 'rgba(255, 255, 255, 0.05)'); + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.font = canvasFont(context, bodyFont, 9, 500); + ctx.textBaseline = 'middle'; + ctx.fillText('Accuracy', element.x + 22, bottomY + 14); + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 11, 700); + ctx.textAlign = 'right'; + ctx.fillText(acc, element.x + 14 + cellW - 8, bottomY + 14); + + const cell2X = element.x + 14 + cellW + 8; + fillRoundedRect(ctx, cell2X, bottomY, cellW, 28, 8, 'rgba(255, 255, 255, 0.05)'); + ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.font = canvasFont(context, bodyFont, 9, 500); + ctx.fillText('Plays', cell2X + 8, bottomY + 14); + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 11, 700); + ctx.textAlign = 'right'; + ctx.fillText(plays, cell2X + cellW - 8, bottomY + 14); + ctx.restore(); + } else { + // full + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(0, 0, 0, 0.55)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(255, 255, 255, 0.1)', + 1 + ); + + const avatarSize = 48; + const avatarX = element.x + 16; + const avatarY = element.y + 16; + fillRoundedRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 12, 'rgba(20, 20, 28, 0.9)'); + strokeRoundedRect( + ctx, + avatarX, + avatarY, + avatarSize, + avatarSize, + 12, + 'rgba(255, 255, 255, 0.15)', + 1 + ); + drawIcon( + context, + 'user', + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize * 0.48, + accent, + 1.5 + ); + + const textX = avatarX + avatarSize + 14; + ctx.save(); + ctx.textAlign = 'left'; + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 14, 700); + ctx.textBaseline = 'top'; + ctx.fillText(name, textX, avatarY + 3); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.font = canvasFont(context, bodyFont, 11, 600); + ctx.fillText(`Lv.${level}`, textX, avatarY + 26); + const lvW = ctx.measureText(`Lv.${level}`).width; + + const barX = textX + lvW + 8; + const barY = avatarY + 30; + const barW = Math.max(50, Math.min(100, element.width - barX - 60)); + fillRoundedRect(ctx, barX, barY, barW, 6, 3, 'rgba(255, 255, 255, 0.1)'); + fillRoundedRect(ctx, barX, barY, barW * (levelProgress / 100), 6, 3, accent); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + ctx.font = '500 10px monospace'; + ctx.fillText(`${levelProgress}%`, barX + barW + 8, barY - 2); + + const divY = avatarY + avatarSize + 16; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.beginPath(); + ctx.moveTo(element.x + 16, divY); + ctx.lineTo(element.x + element.width - 16, divY); + ctx.stroke(); + + const gridY = divY + 12; + const gap = 8; + const cellW = (element.width - 32 - gap) / 2; + const cellH = Math.max(34, (element.height - (gridY - element.y) - 14 - gap) / 2); + + const stats = [ + { label: 'Rank', value: rank, valueColor: '#ffffff' }, + { label: 'Performance', value: pp, valueColor: accent }, + { label: 'Accuracy', value: acc, valueColor: '#ffffff' }, + { label: 'Plays', value: plays, valueColor: '#ffffff' }, + ]; + + stats.forEach((st, idx) => { + const col = idx % 2; + const row = Math.floor(idx / 2); + const cx = element.x + 16 + col * (cellW + gap); + const cy = gridY + row * (cellH + gap); + + fillRoundedRect(ctx, cx, cy, cellW, cellH, 10, 'rgba(255, 255, 255, 0.05)'); + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.font = canvasFont(context, bodyFont, 9, 500); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText(st.label, cx + 10, cy + 6); + + ctx.fillStyle = st.valueColor; + ctx.font = canvasFont(context, bodyFont, 12, 700); + ctx.fillText(st.value, cx + 10, cy + cellH - 18); + }); + ctx.restore(); + } + } else if (element.widget === 'recent-activity') { + if (element.variant === 'minimal') { + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(0, 0, 0, 0.55)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(255, 255, 255, 0.1)', + 1 + ); + + const centerY = element.y + element.height / 2; + const coverSize = Math.min(38, element.height - 14); + const coverX = element.x + 10; + const coverY = centerY - coverSize / 2; + + fillRoundedRect(ctx, coverX, coverY, coverSize, coverSize, 8, 'rgba(25, 20, 45, 0.9)'); + strokeRoundedRect( + ctx, + coverX, + coverY, + coverSize, + coverSize, + 8, + 'rgba(255, 255, 255, 0.15)', + 1 + ); + drawIcon( + context, + 'music', + coverX + coverSize / 2, + coverY + coverSize / 2, + coverSize * 0.45, + '#60a5fa', + 1.5 + ); + + const textX = coverX + coverSize + 10; + ctx.save(); + ctx.textAlign = 'left'; + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 12, 700); + ctx.textBaseline = 'bottom'; + ctx.fillText('Space Pirates', textX, centerY - 1); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.55)'; + ctx.font = canvasFont(context, bodyFont, 10, 500); + ctx.textBaseline = 'top'; + ctx.fillText('Waterflame · 5d ago', textX, centerY + 1); + + const badgeW = 24; + const badgeH = 20; + const badgeX = element.x + element.width - badgeW - 10; + fillRoundedRect(ctx, badgeX, centerY - badgeH / 2, badgeW, badgeH, 4, 'rgba(251, 191, 36, 0.2)'); + strokeRoundedRect( + ctx, + badgeX, + centerY - badgeH / 2, + badgeW, + badgeH, + 4, + 'rgba(251, 191, 36, 0.4)', + 1 + ); + ctx.fillStyle = '#fbbf24'; + ctx.font = '900 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('S', badgeX + badgeW / 2, centerY); + ctx.restore(); + } else if (element.variant === 'compact') { + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(0, 0, 0, 0.55)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(255, 255, 255, 0.1)', + 1 + ); + + ctx.save(); + roundRect(ctx, element.x, element.y, element.width, element.height, 16); + ctx.clip(); + + const bannerH = Math.min(60, element.height * 0.55); + const bannerGrad = ctx.createLinearGradient( + element.x, + element.y, + element.x, + element.y + bannerH + ); + bannerGrad.addColorStop(0, '#1e1b4b'); + bannerGrad.addColorStop(1, '#09090b'); + ctx.fillStyle = bannerGrad; + ctx.fillRect(element.x, element.y, element.width, bannerH); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.font = canvasFont(context, bodyFont, 9, 700); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('RECENT ACTIVITY', element.x + 12, element.y + 8); + + const badgeW = 20; + const badgeH = 16; + const badgeX = element.x + element.width - badgeW - 12; + fillRoundedRect(ctx, badgeX, element.y + 7, badgeW, badgeH, 4, 'rgba(251, 191, 36, 0.2)'); + strokeRoundedRect( + ctx, + badgeX, + element.y + 7, + badgeW, + badgeH, + 4, + 'rgba(251, 191, 36, 0.4)', + 1 + ); + ctx.fillStyle = '#fbbf24'; + ctx.font = '900 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('S', badgeX + badgeW / 2, element.y + 7 + badgeH / 2); + + ctx.textAlign = 'left'; + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 12, 700); + ctx.fillText('Space Pirates', element.x + 12, element.y + bannerH - 18); + + const bottomCenterY = element.y + bannerH + (element.height - bannerH) / 2; + ctx.fillStyle = accent; + ctx.font = canvasFont(context, bodyFont, 10, 600); + ctx.textBaseline = 'middle'; + ctx.fillText('[Galactic Thieves]', element.x + 12, bottomCenterY); + + ctx.textAlign = 'right'; + ctx.fillStyle = '#fbbf24'; + ctx.font = canvasFont(context, bodyFont, 10, 700); + ctx.fillText('Rank #1', element.x + element.width - 12, bottomCenterY); + ctx.restore(); + } else { + // full + fillRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(0, 0, 0, 0.55)' + ); + strokeRoundedRect( + ctx, + element.x, + element.y, + element.width, + element.height, + 16, + 'rgba(255, 255, 255, 0.1)', + 1 + ); + + ctx.save(); + roundRect(ctx, element.x, element.y, element.width, element.height, 16); + ctx.clip(); + + const bannerH = 80; + const bannerGrad = ctx.createLinearGradient( + element.x, + element.y, + element.x, + element.y + bannerH + ); + bannerGrad.addColorStop(0, '#1e1b4b'); + bannerGrad.addColorStop(1, '#09090b'); + ctx.fillStyle = bannerGrad; + ctx.fillRect(element.x, element.y, element.width, bannerH); + + drawPill( + context, + element.x + 12, + element.y + 16, + 'RECENT ACTIVITY', + 'rgba(255, 255, 255, 0.9)', + 'rgba(0, 0, 0, 0.5)', + 'rgba(255, 255, 255, 0.1)' + ); + + const badgeW = 24; + const badgeH = 18; + const badgeX = element.x + element.width - badgeW - 14; + fillRoundedRect(ctx, badgeX, element.y + 8, badgeW, badgeH, 4, 'rgba(251, 191, 36, 0.2)'); + strokeRoundedRect( + ctx, + badgeX, + element.y + 8, + badgeW, + badgeH, + 4, + 'rgba(251, 191, 36, 0.4)', + 1 + ); + ctx.fillStyle = '#fbbf24'; + ctx.font = '900 11px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('S', badgeX + badgeW / 2, element.y + 8 + badgeH / 2); + + ctx.textAlign = 'left'; + ctx.fillStyle = '#ffffff'; + ctx.font = canvasFont(context, bodyFont, 13, 700); + ctx.textBaseline = 'top'; + ctx.fillText('Space Pirates', element.x + 12, element.y + 44); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.65)'; + ctx.font = canvasFont(context, bodyFont, 10, 500); + ctx.fillText('Waterflame', element.x + 12, element.y + 60); + + const bodyTop = element.y + bannerH + 12; + ctx.fillStyle = accent; + ctx.font = canvasFont(context, bodyFont, 11, 600); + ctx.fillText('[Galactic Thieves]', element.x + 12, bodyTop); + + drawPill( + context, + element.x + element.width - 70, + bodyTop + 6, + 'Rank #1', + '#fbbf24', + 'rgba(251, 191, 36, 0.12)', + 'rgba(251, 191, 36, 0.25)' + ); + + const divY = bodyTop + 24; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.beginPath(); + ctx.moveTo(element.x + 12, divY); + ctx.lineTo(element.x + element.width - 12, divY); + ctx.stroke(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + ctx.font = canvasFont(context, bodyFont, 10, 500); + ctx.fillText('5d ago', element.x + 12, divY + 8); + + ctx.textAlign = 'right'; + ctx.fillText('ez-pp.farm', element.x + element.width - 12, divY + 8); + + ctx.restore(); + } + } +}; + +const drawDashboardCards = async (context: PreviewContext, chrome: ThemeV2Chrome) => { + if (chrome.playerCard !== false) { + const playerWidget: ThemeV2WidgetElement = { + id: '__dashboard_player_card__', + type: 'widget', + widget: 'player-card', + variant: 'full', + x: SIDEBAR_WIDTH + 32, + y: 190, + width: 380, + height: 220, + }; + await drawWidgetElement(context, playerWidget); + } + + if (chrome.recentActivity !== false) { + const activityWidget: ThemeV2WidgetElement = { + id: '__dashboard_activity_card__', + type: 'widget', + widget: 'recent-activity', + variant: 'full', + x: SIDEBAR_WIDTH + 32 + (chrome.playerCard !== false ? 400 : 0), + y: 190, + width: 380, + height: 220, + }; + await drawWidgetElement(context, activityWidget); + } +}; + const transformText = (text: string, transform: ThemeV2TextElement['textTransform']) => { if (transform === 'uppercase') return text.toUpperCase(); if (transform === 'lowercase') return text.toLowerCase(); @@ -2079,6 +2934,8 @@ const drawElement = async ( ctx.textBaseline = 'middle'; ctx.fillText('CUSTOM SVELTE LAYER', centerX, centerY); ctx.restore(); + } else if (element.type === 'widget') { + await drawWidgetElement(context, element); } ctx.restore(); @@ -2135,11 +2992,23 @@ export const renderThemePreview = async ({ } } + const chrome = normalizeChrome(definition.chrome, definition.elements); + await drawBackground(context); drawSidebar(context, logo); - drawHeader(context); - drawStatsCard(context); - drawFooter(context); + if (chrome.header !== false) { + drawHeader(context); + } + if (chrome.layoutMode === 'dashboard') { + await drawDashboardCards(context, chrome); + } else if (chrome.layoutMode === 'classic') { + if (chrome.playerCard !== false) { + drawStatsCard(context); + } + } + if (chrome.dock !== false) { + drawFooter(context, chrome); + } for (const element of definition.elements ?? []) { const ancestors = themeGroupAncestors(definition.groups, element); if (element.hidden || ancestors.some((group) => group.hidden)) continue; diff --git a/src/lib/themeV2.ts b/src/lib/themeV2.ts index 0148fe5..ab75c2b 100644 --- a/src/lib/themeV2.ts +++ b/src/lib/themeV2.ts @@ -510,6 +510,33 @@ export type ThemeV2SvelteElement = ThemeV2ElementBase & { source: string; }; +export type ThemeV2WidgetKind = + | 'player-card' + | 'recent-activity' + | 'launch-button' + | 'telemetry' + | 'header' + | 'avatar'; + +export type ThemeV2WidgetVariant = + | 'primary' + | 'full' + | 'compact' + | 'minimal' + | 'icon-only' + | 'circle' + | 'rounded' + | 'square'; + +export type ThemeV2WidgetElement = ThemeV2ElementBase & { + type: 'widget'; + widget: ThemeV2WidgetKind; + variant?: ThemeV2WidgetVariant; + showCountry?: boolean; + showLevel?: boolean; + showOnlineRing?: boolean; +}; + export type ThemeV2Element = | ThemeV2TextElement | ThemeV2DataElement @@ -526,7 +553,20 @@ export type ThemeV2Element = | ThemeV2ParticlesElement | ThemeV2ClockElement | ThemeV2CountdownElement - | ThemeV2SvelteElement; + | ThemeV2SvelteElement + | ThemeV2WidgetElement; + +export type ThemeV2LayoutMode = 'dashboard' | 'classic' | 'custom'; + +export type ThemeV2Chrome = { + layoutMode?: ThemeV2LayoutMode; + header?: boolean; + pills?: boolean; + playerCard?: boolean; + recentActivity?: boolean; + dock?: boolean; + dockTelemetry?: boolean; +}; export type ThemeV2ScriptScreen = 'home' | 'themes' | 'settings' | 'login'; export type ThemeV2ScriptValue = string | number | boolean; @@ -624,6 +664,8 @@ export type ThemeV2Definition = { accentColor?: string; /** Optional CSS appended after the launcher's own styles. */ css?: string; + /** Launcher page layout mode and visibility of chrome components. */ + chrome?: ThemeV2Chrome; }; export type ThemeV2Asset = { @@ -912,6 +954,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin 'clock', 'countdown', 'svelte', + 'widget', ] as const, 'text' ); @@ -932,7 +975,7 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin opacity: clamp(source.opacity, 1, 0, 1), hidden: source.hidden === true, locked: source.locked === true, - interactive: source.interactive === true, + interactive: source.interactive === true || type === 'button' || type === 'widget', groupId: typeof source.groupId === 'string' ? source.groupId.slice(0, 64) : undefined, filters: normalizeFilters(source.filters), mask: normalizeMask(source.mask), @@ -1190,6 +1233,41 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin }; } + if (type === 'widget') { + const widget = oneOf( + source.widget, + ['player-card', 'recent-activity', 'launch-button', 'telemetry', 'header', 'avatar'] as const, + 'launch-button' + ); + const variant = oneOf( + source.variant, + [ + 'primary', + 'full', + 'compact', + 'minimal', + 'icon-only', + 'circle', + 'rounded', + 'square', + ] as const, + widget === 'launch-button' ? 'primary' : widget === 'avatar' ? 'circle' : 'full' + ); + const showCountry = typeof source.showCountry === 'boolean' ? source.showCountry : true; + const showLevel = typeof source.showLevel === 'boolean' ? source.showLevel : false; + const showOnlineRing = + typeof source.showOnlineRing === 'boolean' ? source.showOnlineRing : true; + return { + ...base, + type, + widget, + variant, + showCountry, + showLevel, + showOnlineRing, + }; + } + const src = text(source.src).slice(0, 500); if (!src) return undefined; return { @@ -1518,7 +1596,15 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T } if (type === 'button') { return normalizeElement( - { ...base, type, label: 'Open themes', width: 220, height: 52, y: 320 }, + { + ...base, + type, + label: 'Open themes', + action: { type: 'navigate', screen: 'themes' }, + width: 220, + height: 52, + y: 320, + }, index )! as ThemeV2ButtonElement; } @@ -1572,6 +1658,18 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T index )! as ThemeV2SvelteElement; } + if (type === 'widget') { + return normalizeElement( + { + ...base, + type, + widget: 'launch-button', + width: 220, + height: 60, + }, + index + )! as ThemeV2WidgetElement; + } return normalizeElement( { ...base, type, text: 'Your text', fontSize: 40, width: 420, height: 90 }, index @@ -1581,6 +1679,58 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T export const isThemeV2 = (apiVersion: unknown) => typeof apiVersion === 'string' && apiVersion.split('.')[0] === '2'; +export const normalizeChrome = (raw: unknown, elements: ThemeV2Element[] = []): ThemeV2Chrome => { + const hasCustomElements = elements.length > 0; + if (!raw || typeof raw !== 'object') { + if (hasCustomElements) { + return { + layoutMode: 'classic', + header: true, + pills: false, + playerCard: true, + recentActivity: false, + dock: true, + dockTelemetry: true, + }; + } + return { + layoutMode: 'dashboard', + header: true, + pills: true, + playerCard: true, + recentActivity: true, + dock: true, + dockTelemetry: true, + }; + } + const source = raw as Record; + const layoutMode = oneOf( + source.layoutMode, + ['dashboard', 'classic', 'custom'] as const, + hasCustomElements ? 'classic' : 'dashboard' + ); + return { + layoutMode, + header: source.header !== false, + pills: source.pills !== false, + playerCard: source.playerCard !== false, + recentActivity: source.recentActivity !== false, + dock: source.dock !== false, + dockTelemetry: source.dockTelemetry !== false, + }; +}; + +export const themeHasLaunchButton = ( + definition: Pick +): boolean => { + if (definition.chrome?.dock !== false) return true; + return definition.elements.some( + (el) => + (el.type === 'widget' && el.widget === 'launch-button') || + (el.type === 'button' && el.action.type === 'launch') + ); +}; + export const createDefaultThemeV2 = (): ThemeV2Definition => ({ name: 'Untitled theme', version: '1.0.0', @@ -1617,6 +1767,15 @@ export const createDefaultThemeV2 = (): ThemeV2Definition => ({ behaviors: [], accentColor: '#ff006e', css: '', + chrome: { + layoutMode: 'dashboard', + header: true, + pills: true, + playerCard: true, + recentActivity: true, + dock: true, + dockTelemetry: true, + }, }); /** Coerce a theme.json payload into the supported v2 shape. */ @@ -1731,6 +1890,7 @@ export const normalizeThemeV2 = (raw: Partial): ThemeV2Defini variables: normalizeVariableDefaults(raw.variables), accentColor: color(raw.accentColor, '#ff006e'), css: typeof raw.css === 'string' ? raw.css : '', + chrome: normalizeChrome(raw.chrome, elements), }; }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 75ba289..6a72286 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -55,6 +55,32 @@ export const formatTimeReadable = (initialSeconds: number) => { return result.trim(); }; +export const formatRelativeTime = (dateOrTimestamp: Date | string | number | undefined): string => { + if (!dateOrTimestamp) return 'recently'; + const time = new Date(dateOrTimestamp).getTime(); + if (isNaN(time)) return 'recently'; + const diff = Date.now() - time; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +}; + +export const countryCodeToEmoji = (code?: string): string => { + if (!code || code.length !== 2) return '🌐'; + const codePoints = code + .toUpperCase() + .split('') + .map((c) => 127397 + c.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + export const releaseStreamToReadable = (releaseStream: string) => { if (releaseStream.replaceAll(' ', '').toLowerCase() === 'cuttingedge') return 'Cutting Edge'; return 'Stable'; diff --git a/src/screens/Launch.svelte b/src/screens/Launch.svelte index 653ebd6..253f633 100644 --- a/src/screens/Launch.svelte +++ b/src/screens/Launch.svelte @@ -104,9 +104,11 @@ } 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'; @@ -148,6 +150,17 @@ setCurrentTimeGlobalMedia, urlIsValidImage, } from '$lib/utils'; + import { + ChevronLeft, + ChevronRight, + Clock, + ExternalLink, + Medal, + Sparkles, + 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'; @@ -619,16 +632,164 @@ let downloadingEZPPFiles = $state(false); let cleanup = $state(false); + let customGamemode = $state(undefined); let selectedGamemode = $derived( - getGamemodeInt(modeIntToStr($preferredMode), typeIntToStr($preferredType)) + customGamemode ?? getGamemodeInt(modeIntToStr($preferredMode), typeIntToStr($preferredType)) ); let selectedMode = $derived(getModeAndTypeFromGamemode(selectedGamemode).mode); let selectedType = $derived(getModeAndTypeFromGamemode(selectedGamemode).type); const updateGamemode = (newGamemode: string) => { - selectedGamemode = Number(newGamemode); + 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 latestEvent = $derived(activeEvent ?? userEvents[0]); + + 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(() => { + 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(() => { + 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); @@ -1906,7 +2067,7 @@
{#if selectedView === 'HOME'}
-
-
- {#if $osuInstallationPath !== ''} -
- - - {#if !$beatmapSets && $beatmapSets !== 0} - - {:else} - {numberHumanReadable($beatmapSets ?? 0)} - {/if} + + {#if activeChrome.header !== false} +
+
+
+ + EZPPLauncher
-
- - - {#if !$skinsCount && $skinsCount !== 0} - - {:else} - {numberHumanReadable($skinsCount ?? 0)} - {/if} - -
- {/if} -
- {#if $serverConnectionFails > 1} - - {:else} - - {/if} - -
- -
-
- {#if $reduceAnimations} - {$serverPing}ms - {:else} - - {/if} -
+ + {timeGreeting}, + {$currentUser?.name ?? 'Guest'}!
- - {#if $osuInstallationPath !== ''} -
- - - {#if $osuStream} - {releaseStreamToReadable($osuStream)} - {:else} - - {/if} - - - {#if $osuBuild} - {$osuBuild} - {:else} - - {/if} - - - {#if $currentSkin} - {#if $currentSkin.length > 23} - {$currentSkin.slice(0, 23) + '...'} - {:else} - {$currentSkin} - {/if} - {:else} - - {/if} - - {/if}
+ {:else} +
+ {/if} - 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()} - /> -
-
- {#if $currentUserInfo} -
- - -
- {#if selectedMode === 0} - - {:else if selectedMode === 1} - - {:else if selectedMode === 2} - - {:else if selectedMode === 3} - - {/if} - {getGamemodeName(modeIntToStr(selectedMode), typeIntToStr(selectedType))} -
-
- - {#each validModeTypeCombinationsSorted as gamemode (gamemode)} - {@const gamemod = getModeAndTypeFromGamemode(gamemode)} - -
- {#if gamemod.mode === 0} - - {:else if gamemod.mode === 1} - - {:else if gamemod.mode === 2} - - {:else if gamemod.mode === 3} - - {/if} - {getGamemodeName(modeIntToStr(gamemod.mode), typeIntToStr(gamemod.type))} + + {#if activeChrome.layoutMode === 'dashboard'} + {#if activeChrome.playerCard !== false || activeChrome.recentActivity !== false} +
+ {#if activeChrome.playerCard !== false} + {#if $currentUserInfo} + +
+ +
+
+
+ + + + + + + {#if $currentUserInfo.info.country} + + {countryCodeToEmoji($currentUserInfo.info.country)} + + {/if} +
+ +
+
+ + {$currentUser?.name ?? 'Player'} + + +
+ + +
+ + Lv.{currentStats?.level ?? 1} + +
+ +
+ + {currentStats?.level_progress ?? 0}% + +
+
- - {/each} - - -
-
- Rank -
- {#if $currentUserInfo} -
- {#if $reduceAnimations} - - #{numberHumanReadable( - $currentUserInfo.stats[selectedGamemode].rank ?? 0 + + +
+ + +
+ {#if selectedMode === 0} + + {:else if selectedMode === 1} + + {:else if selectedMode === 2} + + {:else if selectedMode === 3} + + {/if} + {getGamemodeName( + modeIntToStr(selectedMode), + typeIntToStr(selectedType) + )} +
+
+ + {#each validModeTypeCombinationsSorted as gamemode (gamemode)} + {@const gamemod = getModeAndTypeFromGamemode(gamemode)} + +
+ {#if gamemod.mode === 0} + + {:else if gamemod.mode === 1} + + {:else if gamemod.mode === 2} + + {:else if gamemod.mode === 3} + + {/if} + {getGamemodeName( + modeIntToStr(gamemod.mode), + typeIntToStr(gamemod.type) + )} +
+
+ {/each} +
+
+
+
+ + +
+ +
+
+ Rank + {#if currentStats?.country_rank} + + #{currentStats.country_rank} + {countryCodeToEmoji($currentUserInfo.info.country)} + + {/if} +
+
+ {#if $reduceAnimations} + #{numberHumanReadable(currentStats?.rank ?? 0)} + {:else} + + {/if} +
+
+ + +
+ Performance +
+ {#if $reduceAnimations} + {numberHumanReadable(currentStats?.pp ?? 0)}pp + {:else} + + {/if} +
+
+ + +
+ Accuracy +
+ {#if $reduceAnimations} + {(currentStats?.acc ?? 0).toFixed(2)}% + {:else} + + {/if} +
+
+ + +
+ Plays +
+ {#if $reduceAnimations} + {numberHumanReadable(currentStats?.plays ?? 0)} + {:else} + + {/if} +
+
+
+
+ {:else} + +
+
+
+ +
+
+

Welcome to EZPPFarm

+

+ Sign in to view your live stats and progress +

+
+
+ +

+ Log in to track your global and country rank, PP, accuracy, level progression, + and recent play scores directly on your dashboard. +

+ +
+ + +
+
+ {/if} + {/if} + + + {#if activeChrome.recentActivity !== false && $currentUserInfo} + {#if activeEvent} + {@const gradeInfo = gradeBadgeStyle(activeEvent.grade)} +
+ +
+ {#if activeEvent.setId} + {activeEvent.title} { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> + {/if} +
+ + +
+
+ + Recent Play + + {#if userEvents.length > 1} +
+ + {safeEventIndex + 1}/{userEvents.length} + +
+ {/if} +
+ + +
+ {gradeInfo.label} +
+
+ + +
+ + {activeEvent.title} + + + {activeEvent.artist} + +
+
+ + +
+ +
+
+ + [{activeEvent.version}] + +
+
+ {#if activeEvent.mode === 0} + + Standard + {:else if activeEvent.mode === 1} + + Taiko + {:else if activeEvent.mode === 2} + + Catch + {:else if activeEvent.mode === 3} + + Mania + {/if} +
+
+ + +
+ +
+ {#if activeEvent.rank === 1} + +
+ Top Rank + #1 Global +
+ {:else if activeEvent.rank && activeEvent.rank <= 3} + +
+ Podium + Rank #{activeEvent.rank} +
+ {:else if activeEvent.rank} +
+ # +
+
+ Placement + Rank #{activeEvent.rank} +
+ {:else} +
+ Score + Completed +
+ {/if} +
+ + +
+
+ {#if activeEvent.event === 'GAINED'} + + Gained + + {:else if activeEvent.event === 'LOST'} + + Lost + + {:else} + Play Achieved + {/if} +
+ + + {formatRelativeTime(activeEvent.time)} + +
+
+ + + {#if userEvents.length > 1} +
+ {#each userEvents.slice(0, 4) as event, idx} + {@const eventGrade = gradeBadgeStyle(event.grade)} + + {/each} +
+ {/if} +
+ + +
+ + b/{activeEvent.mapId} + + +
+
- {:else} -
- -
- {/if} +
-
-
- PP -
- {#if $currentUserInfo} -
- {#if $reduceAnimations} - - {numberHumanReadable( - $currentUserInfo.stats[selectedGamemode].pp ?? 0 - )}pp - - {:else} - - {/if} -
- {:else} -
- -
- {/if} + {:else} + +
+
+ +
+ No Recent Plays +

+ Launch osu! and play a beatmap to see your latest performance highlighted + here. +

-
-
- Accuracy -
- {#if $currentUserInfo} -
- {#if $reduceAnimations} - - {($currentUserInfo.stats[selectedGamemode].acc ?? 0).toFixed(2)}% - - {:else} - - {/if} -
- {:else} -
- -
- {/if} -
-
-
- Playcount -
- {#if $currentUserInfo} -
- {#if $reduceAnimations} - - {numberHumanReadable( - $currentUserInfo.stats[selectedGamemode].plays ?? 0 - )} - - {:else} - - {/if} -
- {:else} -
- -
- {/if} -
-
-
-
- {:else} -
-
- See your stats here - Log in with your EZPPFarm account to track your rank, PP, accuracy and playcount. -
-
- - -
+ {/if} + {/if}
{/if} -
-
- EZPPLauncher - Hello {$currentUser?.name ?? 'Guest'}! +
+ {#if activeChrome.playerCard !== false} + {#if $currentUserInfo} +
+ + +
+ {#if selectedMode === 0} + + {:else if selectedMode === 1} + + {:else if selectedMode === 2} + + {:else if selectedMode === 3} + + {/if} + {getGamemodeName(modeIntToStr(selectedMode), typeIntToStr(selectedType))} +
+
+ + {#each validModeTypeCombinationsSorted as gamemode (gamemode)} + {@const gamemod = getModeAndTypeFromGamemode(gamemode)} + +
+ {#if gamemod.mode === 0} + + {:else if gamemod.mode === 1} + + {:else if gamemod.mode === 2} + + {:else if gamemod.mode === 3} + + {/if} + {getGamemodeName( + modeIntToStr(gamemod.mode), + typeIntToStr(gamemod.type) + )} +
+
+ {/each} +
+
+
+
+ Rank +
+ #{numberHumanReadable(currentStats?.rank ?? 0)} +
+
+
+ PP +
+ {numberHumanReadable(currentStats?.pp ?? 0)}pp +
+
+
+ Accuracy +
+ {(currentStats?.acc ?? 0).toFixed(2)}% +
+
+
+ Playcount +
+ {numberHumanReadable(currentStats?.plays ?? 0)} +
+
+
+
+ {:else} +
+
+ See your stats here + Log in with your EZPPFarm account to track your rank. +
+ +
+ {/if} + {/if} +
+ {/if} + + + {#if activeChrome.dock !== false} +
-
+ {#if activeChrome.dockTelemetry !== false} +
+ {#if $osuInstallationPath !== ''} +
+ + + {#if !$beatmapSets && $beatmapSets !== 0} + + {:else} + {numberHumanReadable($beatmapSets ?? 0)} + {/if} + +
+
+ + + {#if !$skinsCount && $skinsCount !== 0} + + {:else} + {numberHumanReadable($skinsCount ?? 0)} + {/if} + +
+ {#if $currentSkin} + + {$currentSkin} + + {/if} + {/if} + +
+ {#if $serverConnectionFails > 1} + + {:else} + + {/if} + + {#if !$serverPing || $serverConnectionFails > 1} + + {:else} + {#if $reduceAnimations} + {$serverPing}ms + {:else} + + {/if} + {/if} + +
+ + {#if $osuInstallationPath !== ''} + + {#if $osuStream} + {releaseStreamToReadable($osuStream)} + {:else} + + {/if} + + + {#if $osuBuild} + {$osuBuild} + {:else} + + {/if} + + {/if} +
+ {:else} +
+ {/if} + + 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()} + /> +
+ {:else if !hasThemeLaunchButton} + +
+ 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()} + /> +
+ {/if}
{:else if selectedView === 'SETTINGS'}
1 + ? 'No connection' + : $osuInstallationPath === '' + ? 'No osu! path' + : 'Launch'} + launchDisabled={$osuInstallationPath === '' || $serverConnectionFails > 1 || $launching} oninteraction={triggerThemeScriptElement} onbuttonaction={handleThemeButtonAction} />