From 386c8294558dffb9066c14748139b64ee6daa80c Mon Sep 17 00:00:00 2001 From: HorizonCode Date: Sat, 31 Oct 2026 17:59:07 +0100 Subject: [PATCH] feat: add discord rich presence editor window and interactive embed preview --- src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands.rs | 143 ++ src-tauri/src/lib.rs | 16 +- src-tauri/src/presence.rs | 87 +- src/app.css | 8 +- .../diagnostics/DiagnosticsPanel.svelte | 4 +- .../presence/DiscordPresenceEditor.svelte | 1812 +++++++++++++++++ .../components/theme/ThemeEditorWindow.svelte | 75 +- .../theme/ThemeElementInspector.svelte | 264 ++- .../theme/ThemeGroupInspector.svelte | 34 + src/lib/components/theme/ThemeIcon.svelte | 6 +- .../components/theme/ThemeIconPicker.svelte | 393 ++++ src/lib/components/theme/ThemeStage.svelte | 16 +- .../components/theme/ThemeV2Elements.svelte | 219 +- src/lib/components/ui/tooltip/index.ts | 22 + .../ui/tooltip/tooltip-arrow.svelte | 17 + .../ui/tooltip/tooltip-content.svelte | 29 + .../ui/tooltip/tooltip-portal.svelte | 7 + .../ui/tooltip/tooltip-provider.svelte | 13 + .../ui/tooltip/tooltip-trigger.svelte | 17 + src/lib/components/ui/tooltip/tooltip.svelte | 7 + src/lib/discordPresenceConfig.ts | 417 ++++ src/lib/presence.ts | 27 +- src/lib/themeData.ts | 157 +- src/lib/themeIconCatalog.ts | 206 ++ src/lib/themeIcons.ts | 4 +- src/lib/themePreview.ts | 28 +- src/lib/themeV2.ts | 31 +- src/routes/+layout.svelte | 149 +- src/screens/Launch.svelte | 154 +- 30 files changed, 4102 insertions(+), 263 deletions(-) create mode 100644 src/lib/components/presence/DiscordPresenceEditor.svelte create mode 100644 src/lib/components/theme/ThemeIconPicker.svelte create mode 100644 src/lib/components/ui/tooltip/index.ts create mode 100644 src/lib/components/ui/tooltip/tooltip-arrow.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-portal.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-provider.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 src/lib/components/ui/tooltip/tooltip.svelte create mode 100644 src/lib/discordPresenceConfig.ts create mode 100644 src/lib/themeIconCatalog.ts diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e460c55..9afbcc6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -4,7 +4,8 @@ "description": "Capabilities for the main launcher and theme editor windows", "windows": [ "main", - "theme-editor" + "theme-editor", + "presence-editor" ], "permissions": [ "core:default", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8e50337..ead99a0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1087,6 +1087,35 @@ pub struct PresenceButton { url: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PresenceActivityPayload { + pub state: Option, + pub details: Option, + pub large_image_key: Option, + pub large_image_text: Option, + pub small_image_key: Option>, + pub small_image_text: Option>, + pub button1: Option>, + pub button2: Option>, + pub show_timestamp: Option, +} + +#[tauri::command] +pub async fn presence_update_activity(activity: PresenceActivityPayload) { + presence::update_activity( + activity.state.as_deref(), + activity.details.as_deref(), + activity.large_image_key.as_deref(), + activity.large_image_text.as_deref(), + activity.small_image_key, + activity.small_image_text, + activity.button1, + activity.button2, + activity.show_timestamp, + ); +} + #[tauri::command] pub async fn presence_update_button(button: PresenceButton) { match (button.text.as_deref(), button.url.as_deref()) { @@ -2168,6 +2197,9 @@ pub async fn open_theme_editor(app: AppHandle, theme_folder: Option) -> let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } app.emit_to("theme-editor", "theme-editor-context", ()) .map_err(|error| error.to_string())?; let _ = app.emit_to("main", "theme-editor-state", true); @@ -2194,14 +2226,125 @@ pub async fn open_theme_editor(app: AppHandle, theme_folder: Option) -> let main_window = app.clone(); window.on_window_event(move |event| { if matches!(event, WindowEvent::Destroyed) { + if let Some(main) = main_window.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } let _ = main_window.emit_to("main", "theme-editor-state", false); } }); + + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } let _ = app.emit_to("main", "theme-editor-state", true); Ok(()) } +/// Focus the theme editor window and keep the main launcher window hidden. +#[tauri::command] +pub async fn focus_theme_editor(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("theme-editor") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } + } + Ok(()) +} + +/// Close the theme editor window and restore the main launcher window. +#[tauri::command] +pub async fn close_theme_editor(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("theme-editor") { + let _ = window.close(); + } + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } + let _ = app.emit_to("main", "theme-editor-state", false); + Ok(()) +} + +/// Open (or focus) the discord presence embed editor in its own native window. +#[tauri::command] +pub async fn open_presence_editor(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("presence-editor") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } + let _ = app.emit_to("main", "presence-editor-state", true); + return Ok(()); + } + + let window = + WebviewWindowBuilder::new(&app, "presence-editor", WebviewUrl::App("index.html".into())) + .title("EZPPLauncher — Discord Rich Presence Editor") + .inner_size(1200.0, 800.0) + .min_inner_size(980.0, 650.0) + .resizable(true) + .center() + .build() + .map_err(|error| format!("Could not open the presence editor: {error}"))?; + + let main_window = app.clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Destroyed) { + if let Some(main) = main_window.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } + let _ = main_window.emit_to("main", "presence-editor-state", false); + } + }); + + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } + let _ = app.emit_to("main", "presence-editor-state", true); + + Ok(()) +} + +/// Focus the presence editor window and keep the main launcher window hidden. +#[tauri::command] +pub async fn focus_presence_editor(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("presence-editor") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } + } + Ok(()) +} + +/// Close the presence editor window and restore the main launcher window. +#[tauri::command] +pub async fn close_presence_editor(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("presence-editor") { + let _ = window.close(); + } + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + let _ = main.unminimize(); + let _ = main.set_focus(); + } + let _ = app.emit_to("main", "presence-editor-state", false); + Ok(()) +} + /// Theme data requested by the advanced editor window when it starts or switches themes. #[tauri::command] pub fn theme_editor_context(app: AppHandle) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7aa3c05..653e764 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,18 +18,18 @@ pub mod theme_server; pub mod utils; use crate::{ commands::{ - backup_theme_folder, check_for_corruption, config_all, config_clear, config_delete, + backup_theme_folder, check_for_corruption, close_presence_editor, close_theme_editor, config_all, config_clear, config_delete, config_exists, config_get, config_set, download_and_extract_theme, download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit, - export_theme, extract_theme, file_size, find_osu_installation, get_beatmapsets_count, + export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count, get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream, get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello, has_wmctrl, install_ezpp_launcher_update, installation_diagnostics, is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running, - list_theme_backups, log_frontend, open_logs_folder, open_theme_editor, open_theme_folder, + list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder, open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation, - presence_connect, presence_disconnect, presence_is_connected, presence_update_button, - presence_update_status, presence_update_user, read_theme_info, replace_ui_files, + presence_connect, presence_disconnect, presence_is_connected, presence_update_activity, + presence_update_button, presence_update_status, presence_update_user, read_theme_info, replace_ui_files, resolve_osu_folder, restore_theme_backup, run_open_tablet_driver, run_osu, run_osu_updater, save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values, stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url, @@ -172,6 +172,7 @@ pub fn run() { presence_update_status, presence_update_user, presence_update_button, + presence_update_activity, presence_is_connected, has_osuwinello, has_wmctrl, @@ -189,6 +190,11 @@ pub fn run() { export_theme, open_theme_folder, open_theme_editor, + focus_theme_editor, + close_theme_editor, + open_presence_editor, + focus_presence_editor, + close_presence_editor, theme_editor_context, theme_asset_base, theme_preview_url, diff --git a/src-tauri/src/presence.rs b/src-tauri/src/presence.rs index 1dcc5b4..c98820d 100644 --- a/src-tauri/src/presence.rs +++ b/src-tauri/src/presence.rs @@ -6,13 +6,15 @@ use tauri::{AppHandle, RunEvent}; use tokio::sync::{mpsc, oneshot}; use tokio::time::{Duration, interval}; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct PresenceButton { pub label: String, pub url: String, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct PresenceData { pub state: String, pub details: String, @@ -21,6 +23,8 @@ pub struct PresenceData { pub small_image_key: Option, pub small_image_text: Option, pub dynamic_button: Option, + pub secondary_button: Option, + pub show_timestamp: bool, } impl Default for PresenceData { @@ -32,7 +36,15 @@ impl Default for PresenceData { large_image_text: "EZPPFarm".to_string(), small_image_key: None, small_image_text: None, - dynamic_button: None, + dynamic_button: Some(PresenceButton { + label: "Download the Launcher".to_string(), + url: "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest".to_string(), + }), + secondary_button: Some(PresenceButton { + label: "Join EZPPFarm".to_string(), + url: "https://ez-pp.farm/discord".to_string(), + }), + show_timestamp: true, } } } @@ -162,10 +174,13 @@ impl PresenceActor { let mut builder = ActivityBuilder::new() .state(&self.data.state) .details(&self.data.details) - .start_timestamp(self.start_timestamp as u64) .large_image(&self.data.large_image_key) .large_text(&self.data.large_image_text); + if self.data.show_timestamp { + builder = builder.start_timestamp(self.start_timestamp as u64); + } + if let Some(key) = &self.data.small_image_key { builder = builder.small_image(key); } @@ -174,14 +189,15 @@ impl PresenceActor { } if let Some(btn) = &self.data.dynamic_button { - builder = builder.button(&btn.label, &btn.url); - } else { - builder = builder.button( - "Download the Launcher", - "https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest", - ); + if !btn.label.trim().is_empty() && !btn.url.trim().is_empty() { + builder = builder.button(&btn.label, &btn.url); + } + } + if let Some(btn2) = &self.data.secondary_button { + if !btn2.label.trim().is_empty() && !btn2.url.trim().is_empty() { + builder = builder.button(&btn2.label, &btn2.url); + } } - builder = builder.button("Join EZPPFarm", "https://ez-pp.farm/discord"); let activity = builder.build(); @@ -286,6 +302,55 @@ pub async fn has_presence() -> bool { false } +pub fn update_activity( + state: Option<&str>, + details: Option<&str>, + large_image_key: Option<&str>, + large_image_text: Option<&str>, + small_image_key: Option>, + small_image_text: Option>, + button1: Option>, + button2: Option>, + show_timestamp: Option, +) { + let data = { + let mut guard = PRESENCE_DATA.lock().unwrap(); + if let Some(s) = state { + guard.state = s.to_string(); + } + if let Some(d) = details { + guard.details = d.to_string(); + } + if let Some(img) = large_image_key { + guard.large_image_key = img.to_string(); + } + if let Some(txt) = large_image_text { + guard.large_image_text = txt.to_string(); + } + if let Some(key) = small_image_key { + guard.small_image_key = key; + } + if let Some(text) = small_image_text { + guard.small_image_text = text; + } + if let Some(b1) = button1 { + guard.dynamic_button = b1; + } + if let Some(b2) = button2 { + guard.secondary_button = b2; + } + if let Some(ts) = show_timestamp { + guard.show_timestamp = ts; + } + guard.clone() + }; + + let tx = PRESENCE_TX.clone(); + tokio::spawn(async move { + let _ = tx.send(PresenceCommand::UpdateData(data)).await; + }); +} + pub fn update_status(state: Option<&str>, details: Option<&str>, large_image_key: Option<&str>) { let data = { let mut guard = PRESENCE_DATA.lock().unwrap(); diff --git a/src/app.css b/src/app.css index 93f0009..08ad2cb 100644 --- a/src/app.css +++ b/src/app.css @@ -25,8 +25,8 @@ --secondary-foreground: oklch(40.06% 0.038 266.38); --muted: oklch(89.49% 0.023 256.59); --muted-foreground: oklch(51.01% 0.028 265.36); - --accent: oklch(54.41% 0.186 230.5); - --accent-foreground: oklch(100% 0 0); + --accent: var(--primary); + --accent-foreground: var(--primary-foreground); --destructive: oklch(51.68% 0.202 15.84); --destructive-foreground: oklch(100% 0 0); --border: oklch(78.52% 0.026 260.1); @@ -68,8 +68,8 @@ --secondary-foreground: oklch(89.24% 0.079 263.24); --muted: oklch(24.41% 0.048 271.21); --muted-foreground: oklch(73.83% 0.057 265.65); - --accent: oklch(86.2% 0.131 300.42); - --accent-foreground: oklch(20.22% 0.048 282.86); + --accent: var(--primary); + --accent-foreground: var(--primary-foreground); --destructive: oklch(77.23% 0.146 11.66); --destructive-foreground: oklch(20.22% 0.048 282.86); --border: oklch(27.7% 0.041 276.87); diff --git a/src/lib/components/diagnostics/DiagnosticsPanel.svelte b/src/lib/components/diagnostics/DiagnosticsPanel.svelte index 9f832d0..0760393 100644 --- a/src/lib/components/diagnostics/DiagnosticsPanel.svelte +++ b/src/lib/components/diagnostics/DiagnosticsPanel.svelte @@ -345,7 +345,7 @@ + + + + + + + + +
+ + + + +
+ +
+
+ + Discord Embed + · + Live Interactive Canvas +
+ +
+ +
+ + +
+ + + + +
+ + +
+ + + + +
+ + + + +
+
+
+ + + {#if previewMode === 'playing'} +
+
+ Mode: +
+ {#each ['osu!', 'taiko', 'catch', 'mania'] as mode} + + {/each} +
+
+ +
+ Action: +
+ {#each ['Playing...', 'Editing...', 'AFK...'] as act} + + {/each} +
+
+ +
+ Beatmap: + +
+
+ {/if} + + + + + +
+
+ {#if cardInteractionMode === 'edit'} + + + Click any field on the card to edit directly in the form + + {:else} + + + Interactive test mode — click buttons to test links and actions + + {/if} + | + Esc to close +
+ +
+ Preset: {selectedPresetName} + | + Discord IPC v2 +
+
+
+
+ + +{/if} diff --git a/src/lib/components/theme/ThemeEditorWindow.svelte b/src/lib/components/theme/ThemeEditorWindow.svelte index 7b6a1c8..36b8fc2 100644 --- a/src/lib/components/theme/ThemeEditorWindow.svelte +++ b/src/lib/components/theme/ThemeEditorWindow.svelte @@ -19,6 +19,8 @@ import * as Select from '$lib/components/ui/select'; import Slider from '$lib/components/ui/slider/slider.svelte'; import Textarea from '$lib/components/ui/textarea/textarea.svelte'; + import { config } from '$lib/config'; + import { currentUserInfo } from '$lib/data'; import { defaultFontsourceOptions, downloadFontsourceFont, @@ -28,6 +30,15 @@ themeFontKey, type FontsourceFont, } from '$lib/fontsource'; + import { + launcherVersion, + onlineFriends, + osuBuild, + osuStream, + serverPing, + setupValues, + } from '$lib/global'; + import { getLauncherVersion, getReleaseStream, getVersion } from '$lib/osuUtil'; import { THEME_CLOCK_VARIABLE_NAMES, isThemeClockVariable, @@ -80,6 +91,9 @@ themesDirectory, toSafeName, } from '$lib/themes'; + import type { EZPPUser, EZPPUserInfo } from '$lib/types'; + import { currentUser } from '$lib/userAuthentication'; + import { osuInstallationPath } from '$lib/userSettings'; import { formatBytes } from '$lib/utils'; import { AlertTriangle, @@ -1766,12 +1780,37 @@ } } + type ThemeEditorGlobalSyncPayload = { + user?: EZPPUser | null; + info?: EZPPUserInfo | null; + friends?: number; + osuBuild?: string; + osuStream?: string; + serverPing?: number; + launcherVersion?: string; + }; + onMount(() => { const observer = new ResizeObserver(updateCanvasScale); if (canvasHost) observer.observe(canvasHost); let unlisten: UnlistenFn | undefined; let unlistenClose: UnlistenFn | undefined; + let unlistenGlobalSync: UnlistenFn | undefined; listen('theme-editor-context', () => loadContext()).then((stop) => (unlisten = stop)); + listen('sync-theme-editor-global-data', (event) => { + const data = event.payload; + if (!data) return; + if (data.user !== undefined) currentUser.set(data.user ?? undefined); + if (data.info !== undefined) currentUserInfo.set(data.info ?? undefined); + if (data.friends !== undefined) onlineFriends.set(data.friends); + if (data.osuBuild) osuBuild.set(data.osuBuild); + if (data.osuStream) osuStream.set(data.osuStream); + if (data.serverPing !== undefined) serverPing.set(data.serverPing); + if (data.launcherVersion) launcherVersion.set(data.launcherVersion); + }).then((stop) => (unlistenGlobalSync = stop)); + + emit('request-theme-editor-global-data').catch(() => {}); + getCurrentWindow() .onCloseRequested((event) => { if (allowClose || !dirty) return; @@ -1780,11 +1819,37 @@ }) .then((stop) => (unlistenClose = stop)); loadContext(); + getLauncherVersion() + .then((ver) => { + if (ver) launcherVersion.set(ver); + }) + .catch(() => {}); + + const teardownValues = setupValues(); + + config + .value('osu_installation_path') + .get('') + .then(async (path) => { + if (!path) return; + osuInstallationPath.set(path); + try { + const [stream, version] = await Promise.all([ + getReleaseStream(path).catch(() => undefined), + getVersion(path).catch(() => undefined), + ]); + if (stream) osuStream.set(stream); + if (version) osuBuild.set(version); + } catch {} + }) + .catch(() => {}); return () => { observer.disconnect(); unlisten?.(); unlistenClose?.(); + unlistenGlobalSync?.(); + teardownValues?.(); stopHistoryTimer(); }; }); @@ -2928,7 +2993,7 @@ return; } allowClose = true; - await getCurrentWindow().close(); + await invoke('close_theme_editor'); } async function saveAndClose() { @@ -2937,14 +3002,14 @@ clearDraft(); allowClose = true; closeDialogOpen = false; - await getCurrentWindow().close(); + await invoke('close_theme_editor'); } async function discardAndClose() { clearDraft(); allowClose = true; closeDialogOpen = false; - await getCurrentWindow().close(); + await invoke('close_theme_editor'); } @@ -5379,6 +5444,10 @@
void; @@ -92,6 +103,70 @@ node.setSelectionRange(caret, caret); }); } + + const currentFieldLabel = $derived( + element.type === 'data' + ? (THEME_DATA_FIELDS.find((f) => f.value === element.field)?.label ?? element.field) + : '' + ); + + const currentRawValue = $derived( + element.type === 'data' + ? resolveThemeDataValue(element.field, { + global: $themeGlobalData, + theme: { + name: themeName || 'Theme', + author: themeAuthor || '', + version: themeVersion || '1.0.0', + }, + screen: 'home', + mode: element.mode, + }) + : '' + ); + + const currentSampleValue = $derived( + element.type === 'data' ? getThemeDataSampleValue(element.field, element.mode) : '' + ); + + const currentEffectiveValue = $derived( + element.type === 'data' + ? currentRawValue && currentRawValue.trim() !== '' + ? currentRawValue + : (element.defaultValue ?? '') + : '' + ); + + const previewDisplayValue = $derived( + element.type === 'data' + ? currentEffectiveValue && currentEffectiveValue.trim() !== '' + ? currentEffectiveValue + : currentSampleValue + : '' + ); + + const previewResult = $derived.by(() => { + if (element.type !== 'data') return ''; + const template = element.text?.trim() ? element.text : '{value}'; + const fallback = element.defaultValue ?? ''; + return template + .replaceAll('{value}', previewDisplayValue) + .replaceAll('{defaultValue}', fallback) + .replaceAll('{fallback}', fallback); + }); + + const parentGroupAncestors = $derived( + element.groupId && groups ? themeGroupAncestors(groups, element) : [] + ); + + const parentGroupMorphs = $derived(parentGroupAncestors.some((group) => group.morph === true)); + + const parentGroupMorphDuration = $derived.by(() => { + for (const group of parentGroupAncestors) { + if (group.morphDuration && group.morphDuration > 0) return group.morphDuration; + } + return undefined; + });
@@ -237,6 +312,63 @@ Interactive
+
+ { + if (parentGroupMorphs) { + element.morph = checked === true ? true : false; + } else { + element.morph = checked === true; + } + }} + /> + + {#if parentGroupMorphs && element.morph !== false} + (group) + {/if} + {#if parentGroupMorphs && element.morph !== undefined} + + {/if} +
+ {#if element.morph === true || (element.morph !== false && parentGroupMorphs)} +
+
+ + + {element.morphDuration ?? (parentGroupMorphDuration || 400)}ms + +
+ { + const val = Number((event.currentTarget as HTMLInputElement).value); + element.morphDuration = Number.isFinite(val) && val > 0 ? val : 400; + }} + /> +
+ {/if} {#if element.type === 'data'} -
+
- {THEME_DATA_FIELDS.find((field) => field.value === element.field)?.label ?? - element.field} + {currentFieldLabel} {#each THEME_DATA_FIELDS as field (field.value)} @@ -281,6 +412,48 @@ {/each} + + +
+
+ Selected Value ({currentFieldLabel}) + {#if currentRawValue && currentRawValue.trim() !== ''} + + Live value + + {:else if element.defaultValue && element.defaultValue.trim() !== ''} + + Default + + {:else if currentSampleValue} + + Sample preview + + {:else} + + Empty + + {/if} +
+
+ + {previewDisplayValue ? previewDisplayValue : '(empty)'} + + {#if !currentRawValue && (!element.defaultValue || element.defaultValue.trim() === '') && currentSampleValue} + (sample preview) + {:else if !currentRawValue && element.defaultValue} + (using default) + {/if} +
+
{#if element.field.startsWith('user.') && element.field !== 'user.name' && element.field !== 'user.country'}
@@ -322,6 +495,26 @@ still work too.

+
+ + +

+ Fallback value used when the data is empty or unavailable (such as when logged out). +

+
+
+
+ Result Preview +
+
+ {previewResult || '(empty)'} +
+
{:else}
@@ -950,27 +1143,9 @@
- { - if (value) element.icon = value as typeof element.icon; - }} - > - - {element.icon} - - - {#each THEME_ICON_NAMES as name (name)} - - {/each} - - +

- Icons follow the theme's icon set, so the same layer switches families with the theme. + Choose from over 17,000 icons. Launcher icons adapt to the theme's icon family.

@@ -1033,25 +1208,7 @@
- { - if (value) element.icon = value as typeof element.icon; - }} - > - - {element.icon ?? 'music'} - - - {#each THEME_ICON_NAMES as name (name)} - - {/each} - - +
@@ -1439,26 +1596,7 @@
- { - element.icon = value && value !== 'none' ? (value as typeof element.icon) : undefined; - }} - > - - {element.icon ?? 'None'} - - - - {#each THEME_ICON_NAMES as name (name)} - - {/each} - - +
diff --git a/src/lib/components/theme/ThemeGroupInspector.svelte b/src/lib/components/theme/ThemeGroupInspector.svelte index f4558d8..973bf01 100644 --- a/src/lib/components/theme/ThemeGroupInspector.svelte +++ b/src/lib/components/theme/ThemeGroupInspector.svelte @@ -114,6 +114,40 @@ />
+
+ (group.morph = checked === true)} + /> + +
+ {#if group.morph} +
+
+ + {group.morphDuration ?? 400}ms +
+ { + const val = Number((event.currentTarget as HTMLInputElement).value); + group.morphDuration = Number.isFinite(val) && val > 0 ? val : 400; + }} + /> +
+ {/if} - import { THEME_ICON_BODIES } from '$lib/themeIconData'; + import { getThemeIconBody } from '$lib/themeIconCatalog'; import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons'; let { @@ -14,9 +14,7 @@ class?: string; } = $props(); - const icon = $derived( - THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name] - ); + const icon = $derived(getThemeIconBody(name, set)); + import Button from '$lib/components/ui/button/button.svelte'; + import Input from '$lib/components/ui/input/input.svelte'; + import * as Popover from '$lib/components/ui/popover'; + import { + getFullIconCatalog, + type IconPackKey, + type ThemeIconCatalogItem, + } from '$lib/themeIconCatalog'; + import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons'; + import ThemeIcon from './ThemeIcon.svelte'; + import { Check, ChevronDown, ChevronsUpDown, LayoutGrid, List, Search, X } from '@lucide/svelte'; + + let { + value = $bindable(undefined), + iconSet = DEFAULT_THEME_ICON_SET, + placeholder = 'Select icon...', + disabled = false, + allowNone = false, + onchange, + }: { + value?: string; + iconSet?: ThemeIconSet; + placeholder?: string; + disabled?: boolean; + allowNone?: boolean; + onchange?: (val: string | undefined) => void; + } = $props(); + + let open = $state(false); + let searchQuery = $state(''); + let selectedPack = $state('all'); + let viewMode = $state<'grid' | 'list'>('grid'); + let scrollContainer = $state(null); + let scrollTop = $state(0); + + const containerHeight = 280; + const gridColumns = 5; + const gridRowHeight = 54; + const listRowHeight = 34; + const overscan = 4; + + const rowHeight = $derived(viewMode === 'grid' ? gridRowHeight : listRowHeight); + + const catalog = getFullIconCatalog(); + + const packTabs: { key: IconPackKey | 'all'; label: string; count: number }[] = [ + { key: 'all', label: 'All', count: catalog.length }, + { key: 'launcher', label: 'Launcher', count: 47 }, + { key: 'lucide', label: 'Lucide', count: 2078 }, + { key: 'phosphor', label: 'Phosphor', count: 9161 }, + { key: 'tabler', label: 'Tabler', count: 6305 }, + ]; + + const filteredItems = $derived.by(() => { + let items = catalog; + if (selectedPack !== 'all') { + items = items.filter((it) => it.pack === selectedPack); + } + const q = searchQuery.trim().toLowerCase(); + if (q) { + items = items.filter( + (it) => it.name.toLowerCase().includes(q) || it.id.toLowerCase().includes(q) + ); + } + return items; + }); + + const numRows = $derived( + viewMode === 'grid' ? Math.ceil(filteredItems.length / gridColumns) : filteredItems.length + ); + + const totalHeight = $derived(numRows * rowHeight); + + const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)); + const endIndex = $derived( + Math.min(numRows, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan) + ); + + type VirtualRow = { + index: number; + items?: ThemeIconCatalogItem[]; + item?: ThemeIconCatalogItem; + }; + + const visibleRows = $derived.by(() => { + const rows: VirtualRow[] = []; + if (viewMode === 'grid') { + for (let i = startIndex; i < endIndex; i++) { + const rowItems = filteredItems.slice(i * gridColumns, (i + 1) * gridColumns); + rows.push({ index: i, items: rowItems }); + } + } else { + for (let i = startIndex; i < endIndex; i++) { + const item = filteredItems[i]; + if (item) { + rows.push({ index: i, item }); + } + } + } + return rows; + }); + + $effect(() => { + // When search, pack, or view mode changes, reset scroll to top + void searchQuery; + void selectedPack; + void viewMode; + if (scrollContainer) { + scrollContainer.scrollTop = 0; + scrollTop = 0; + } + }); + + function selectIcon(id: string | undefined) { + if (id === 'none' || !id) { + value = undefined; + onchange?.(undefined); + } else { + value = id; + onchange?.(id); + } + open = false; + } + + function displayName(val: string | undefined): string { + if (!val || val === 'none') return 'None'; + if (val.includes(':')) { + return val.slice(val.indexOf(':') + 1); + } + return val; + } + + function displayPack(val: string | undefined): string | null { + if (!val || val === 'none') return null; + if (val.startsWith('lucide:')) return 'Lucide'; + if (val.startsWith('ph:')) return 'Phosphor'; + if (val.startsWith('tabler:')) return 'Tabler'; + return 'Launcher'; + } + + + + + {#if !value || value === 'none'} + + ✕ + {allowNone ? 'None' : placeholder} + + {:else} + + + + + {displayName(value)} + {#if displayPack(value)} + + {displayPack(value)} + + {/if} + + {/if} + + + + + +
+
+ + + {#if searchQuery} + + {/if} +
+
+ + +
+
+ {#each packTabs as tab (tab.key)} + + {/each} +
+ +
+ + +
+
+ + + {#if allowNone} +
+ +
+ {/if} + + +
{ + scrollTop = (e.currentTarget as HTMLElement).scrollTop; + }} + > + {#if filteredItems.length === 0} +
+

No icons found

+ {#if searchQuery} +

"{searchQuery}"

+ {/if} + +
+ {:else} +
+
+ {#if viewMode === 'grid'} + {#each visibleRows as row (row.index)} +
+ {#each row.items ?? [] as item (item.id)} + {@const isSelected = + item.id === value || (item.pack === 'launcher' && item.name === value)} + + {/each} +
+ {/each} + {:else} + {#each visibleRows as row (row.index)} + {#if row.item} + {@const item = row.item} + {@const isSelected = + item.id === value || (item.pack === 'launcher' && item.name === value)} +
+ +
+ {/if} + {/each} + {/if} +
+
+ {/if} +
+ + +
+ {filteredItems.length.toLocaleString()} icons + Theme pack: {iconSet} +
+
+ diff --git a/src/lib/components/theme/ThemeStage.svelte b/src/lib/components/theme/ThemeStage.svelte index bb317dd..1c551e5 100644 --- a/src/lib/components/theme/ThemeStage.svelte +++ b/src/lib/components/theme/ThemeStage.svelte @@ -61,10 +61,14 @@ `font-family:${fontStack(bodyFont)}`, `--primary:${accent}`, `--primary-foreground:${accentText}`, + `--accent:${accent}`, + `--accent-foreground:${accentText}`, `--ring:${accent}`, `--color-primary:${accent}`, `--color-primary-foreground:${accentText}`, `--color-primary-500:${accent}`, + `--color-accent:${accent}`, + `--color-accent-foreground:${accentText}`, `--color-ring:${accent}`, `--font-mono:${monoFontStack(monoFont)}`, ].join(';') @@ -91,11 +95,13 @@
- - 3.2.7 - + {#if variables.launcher_version !== undefined && variables.launcher_version !== ''} + + {variables.launcher_version} + + {/if}
diff --git a/src/lib/components/theme/ThemeV2Elements.svelte b/src/lib/components/theme/ThemeV2Elements.svelte index 5ab3a91..b12e687 100644 --- a/src/lib/components/theme/ThemeV2Elements.svelte +++ b/src/lib/components/theme/ThemeV2Elements.svelte @@ -2,7 +2,7 @@ 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 { resolveThemeDataValue, themeGlobalData } from '$lib/themeData'; + import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData'; import { fontStack } from '$lib/themeFonts'; import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons'; import { @@ -42,6 +42,7 @@ import { Boxes, ChevronRight, Move, RotateCw } from '@lucide/svelte'; import { onDestroy } from 'svelte'; import { SvelteSet } from 'svelte/reactivity'; + import { TextMorph } from 'torph/svelte'; let { elements = [], @@ -155,8 +156,32 @@ mode: element.mode, now: clockNow, }); + const fallback = element.defaultValue ?? ''; + const sample = interactive ? getThemeDataSampleValue(element.field, element.mode) : ''; + const value = + raw && raw.trim() !== '' ? raw : fallback && fallback.trim() !== '' ? fallback : sample; const template = element.text?.trim() ? element.text : '{value}'; - return interpolateThemeText(template.replaceAll('{value}', raw), variables); + return interpolateThemeText( + template + .replaceAll('{value}', value) + .replaceAll('{defaultValue}', fallback) + .replaceAll('{fallback}', fallback), + variables + ); + }; + + const isMorphEnabled = (element: ThemeV2Element): boolean => { + if (element.morph === true) return true; + if (element.morph === false) return false; + return ancestorsOf(element).some((group) => group.morph === true); + }; + + const morphDuration = (element: ThemeV2Element): number => { + if (element.morphDuration && element.morphDuration > 0) return element.morphDuration; + for (const group of ancestorsOf(element)) { + if (group.morphDuration && group.morphDuration > 0) return group.morphDuration; + } + return 400; }; const maskedAncestors = (element: ThemeV2Element) => @@ -1232,6 +1257,10 @@ )}" > {#if element.type === 'text' || element.type === 'data'} + {@const content = + element.type === 'data' + ? dataValue(element) + : interpolateThemeText(element.text, variables)}
- - {element.type === 'data' - ? dataValue(element) - : interpolateThemeText(element.text, variables)} - + {#if isMorphEnabled(element)} + + {:else} + + {content} + + {/if}
{:else if element.type === 'image'} {#if element.tint} @@ -1420,14 +1459,27 @@ /> {#if gaugeLabel(element)} - - {gaugeLabel(element)} - + {@const label = gaugeLabel(element)} + {#if isMorphEnabled(element)} + + {:else} + + {label} + + {/if} {/if}
{:else} @@ -1444,14 +1496,27 @@ >
{#if gaugeLabel(element)} - - {gaugeLabel(element)} - + {@const label = gaugeLabel(element)} + {#if isMorphEnabled(element)} + + {:else} + + {label} + + {/if} {/if}
{/if} @@ -1472,7 +1537,15 @@ 600};color:{element.color ?? '#ffffff'};text-transform:{element.textTransform ?? 'none'};white-space:nowrap;" > - {interpolateThemeText(element.text, variables)} + {#if isMorphEnabled(element)} + + {:else} + {interpolateThemeText(element.text, variables)} + {/if} {/each}
@@ -1506,7 +1579,16 @@ {#if element.icon} {/if} - {interpolateThemeText(element.label, variables)} + {#if isMorphEnabled(element)} + + {:else} + {interpolateThemeText(element.label, variables)} + {/if} {:else if element.type === 'path'} {@const gradientVector = themeSvgGradientVector(element.gradientAngle ?? 135)} @@ -1646,21 +1728,46 @@ '#ff006e'};">{element.label} {/if} - - {themeClockTime(element, now)} - - {#if element.showDate} + {#if isMorphEnabled(element)} + + {:else} {themeClockDate(now)} + {themeClockTime(element, now)} + + {/if} + {#if element.showDate} + {#if isMorphEnabled(element)} + + {:else} + {themeClockDate(now)} + {/if} {/if}
{/if} @@ -1673,13 +1780,25 @@ ? 'flex-end' : 'center'};text-align:{element.align ?? 'center'};" > - - {themeCountdownText(element, clockNow)} - + {#if isMorphEnabled(element)} + + {:else} + + {themeCountdownText(element, clockNow)} + + {/if}
{/if}
diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..6c460e4 --- /dev/null +++ b/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,22 @@ +import Root from "./tooltip.svelte"; +import Content from "./tooltip-content.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Portal from "./tooltip-portal.svelte"; +import Arrow from "./tooltip-arrow.svelte"; + +export { + Root, + Content, + Trigger, + Provider, + Portal, + Arrow, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, + Arrow as TooltipArrow, +}; diff --git a/src/lib/components/ui/tooltip/tooltip-arrow.svelte b/src/lib/components/ui/tooltip/tooltip-arrow.svelte new file mode 100644 index 0000000..c80d201 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-arrow.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..b3c20c3 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/components/ui/tooltip/tooltip-portal.svelte b/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..d234f7d --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-provider.svelte b/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..6766ca3 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,13 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..404e6d0 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip.svelte b/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..0b0f9ce --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/discordPresenceConfig.ts b/src/lib/discordPresenceConfig.ts new file mode 100644 index 0000000..8f38e56 --- /dev/null +++ b/src/lib/discordPresenceConfig.ts @@ -0,0 +1,417 @@ +import { config } from '$lib/config'; +import { updateActivity } from '$lib/presence'; +import { get, writable } from 'svelte/store'; + +export type DiscordPresenceButtonConfig = { + enabled: boolean; + label: string; + url: string; +}; + +export type DiscordPresenceConfig = { + // Idle (Launcher) Configuration + idleDetails: string; + idleState: string; + idleLargeImageKey: string; + idleLargeImageText: string; + idleShowTime: boolean; + idleShowUserAvatar: boolean; + + // Playing (osu!) Configuration + playingDetails: string; + playingState: string; + playingShowBeatmapCover: boolean; + playingLargeImageKey: string; + playingLargeImageText: string; + playingShowUserAvatar: boolean; + playingSmallImageText: string; + playingShowTime: boolean; + + // Contextual Buttons + idleButton1: DiscordPresenceButtonConfig; + playingButton1: DiscordPresenceButtonConfig; + + // Secondary Button + button2: DiscordPresenceButtonConfig; +}; + +export const DEFAULT_DISCORD_PRESENCE_CONFIG: DiscordPresenceConfig = { + idleDetails: 'In Launcher', + idleState: 'Chilling...', + idleLargeImageKey: 'ezppfarm', + idleLargeImageText: 'EZPPFarm Launcher', + idleShowTime: true, + idleShowUserAvatar: true, + + playingDetails: '[{mode}] {action}', + playingState: '{beatmap}', + playingShowBeatmapCover: true, + playingLargeImageKey: 'ezppfarm', + playingLargeImageText: '{beatmap}', + playingShowUserAvatar: true, + playingSmallImageText: '{username} (#{rank})', + playingShowTime: true, + + idleButton1: { + enabled: true, + label: 'Download Launcher', + url: 'https://git.ez-pp.farm/EZPPFarm/EZPPLauncher/releases/latest', + }, + playingButton1: { + enabled: true, + label: 'View Beatmap', + url: '{beatmapUrl}', + }, + button2: { + enabled: true, + label: 'Join EZPPFarm', + url: 'https://ez-pp.farm/discord', + }, +}; + +export type DiscordPresencePreset = { + name: string; + description: string; + config: Partial; +}; + +export const DISCORD_PRESENCE_PRESETS: DiscordPresencePreset[] = [ + { + name: 'EZPPFarm Default', + description: 'Standard presence with beatmap covers, actions, and official links.', + config: DEFAULT_DISCORD_PRESENCE_CONFIG, + }, + { + name: 'Clean & Minimal', + description: 'Clean look without buttons or cluttered stats.', + config: { + idleDetails: 'In Launcher', + idleState: 'Chilling', + idleShowTime: true, + idleShowUserAvatar: false, + playingDetails: '{action}', + playingState: '{beatmap}', + playingShowBeatmapCover: true, + playingShowUserAvatar: false, + playingShowTime: true, + idleButton1: { enabled: false, label: '', url: '' }, + playingButton1: { enabled: false, label: '', url: '' }, + button2: { enabled: false, label: '', url: '' }, + }, + }, + { + name: 'Competitive Stats', + description: 'Highlights your rank, performance points, and live accuracy.', + config: { + idleDetails: '{username} · #{rank} ({pp}pp)', + idleState: 'Preparing to farm...', + idleShowTime: true, + idleShowUserAvatar: true, + playingDetails: '[{mode}] {action} · {accuracy}', + playingState: '{beatmap}', + playingShowBeatmapCover: true, + playingShowUserAvatar: true, + playingSmallImageText: '{username} (#{rank} · {pp}pp)', + playingShowTime: true, + idleButton1: { + enabled: true, + label: 'View Profile', + url: '{profileUrl}', + }, + playingButton1: { + enabled: true, + label: 'View Beatmap', + url: '{beatmapUrl}', + }, + button2: { + enabled: true, + label: 'View Profile', + url: '{profileUrl}', + }, + }, + }, + { + name: 'Streamer / Privacy', + description: 'Hides exact map titles, player rank, and avatar for privacy.', + config: { + idleDetails: 'osu!', + idleState: 'In Launcher', + idleShowTime: false, + idleShowUserAvatar: false, + playingDetails: 'Playing osu!', + playingState: 'Offline practice', + playingShowBeatmapCover: false, + playingShowUserAvatar: false, + playingLargeImageKey: 'ezppfarm', + playingLargeImageText: 'osu!', + playingSmallImageText: '', + playingShowTime: false, + idleButton1: { enabled: false, label: '', url: '' }, + playingButton1: { enabled: false, label: '', url: '' }, + button2: { enabled: false, label: '', url: '' }, + }, + }, +]; + +export type PresenceVariableContext = { + username?: string; + id?: string; + rank?: string | number; + pp?: string | number; + accuracy?: string | number; + mode?: string; + action?: string; + beatmap?: string; + artist?: string; + title?: string; + diff?: string; + version?: string; + beatmapUrl?: string; + profileUrl?: string; +}; + +export const interpolatePresenceString = ( + template: string, + context: PresenceVariableContext +): string => { + if (!template) return ''; + return template + .replaceAll('{username}', context.username ?? 'Player') + .replaceAll( + '{rank}', + context.rank !== undefined && context.rank !== '' ? String(context.rank) : '-' + ) + .replaceAll('{pp}', context.pp !== undefined && context.pp !== '' ? String(context.pp) : '0') + .replaceAll( + '{accuracy}', + context.accuracy !== undefined && context.accuracy !== '' ? String(context.accuracy) : '100%' + ) + .replaceAll('{mode}', context.mode ?? 'osu!') + .replaceAll('{action}', context.action ?? 'Playing...') + .replaceAll('{beatmap}', context.beatmap ?? 'Idle...') + .replaceAll('{artist}', context.artist ?? '') + .replaceAll('{title}', context.title ?? '') + .replaceAll('{diff}', context.diff ?? '') + .replaceAll( + '{version}', + context.version !== undefined && context.version !== '' ? String(context.version) : '' + ) + .replaceAll('{beatmapUrl}', context.beatmapUrl ?? 'https://ez-pp.farm') + .replaceAll( + '{profileUrl}', + context.profileUrl ?? + (context.id ? `https://ez-pp.farm/u/${context.id}` : 'https://ez-pp.farm') + ); +}; + +export const discordPresenceCustomConfig = writable( + DEFAULT_DISCORD_PRESENCE_CONFIG +); + +export const loadDiscordPresenceConfig = async (): Promise => { + try { + const raw = await config + .value>('discord_presence_config') + .get({}); + const merged: DiscordPresenceConfig = { + ...DEFAULT_DISCORD_PRESENCE_CONFIG, + ...raw, + idleButton1: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.idleButton1, ...(raw?.idleButton1 ?? {}) }, + playingButton1: { + ...DEFAULT_DISCORD_PRESENCE_CONFIG.playingButton1, + ...(raw?.playingButton1 ?? {}), + }, + button2: { ...DEFAULT_DISCORD_PRESENCE_CONFIG.button2, ...(raw?.button2 ?? {}) }, + }; + discordPresenceCustomConfig.set(merged); + return merged; + } catch { + return DEFAULT_DISCORD_PRESENCE_CONFIG; + } +}; + +export const saveDiscordPresenceConfig = async ( + newConfig: DiscordPresenceConfig +): Promise => { + discordPresenceCustomConfig.set(newConfig); + try { + await config.value('discord_presence_config').set(newConfig); + } catch (err) { + console.error('Failed to save discord presence config:', err); + } +}; + +export type IdlePresenceParams = { + user?: { id?: number | string; name?: string } | null; + version?: string; + rank?: number | string; + pp?: number | string; + accuracy?: number | string; +}; + +export type PlayingPresenceParams = { + user?: { id?: number | string; name?: string } | null; + mode?: string; + action?: string; + beatmap?: string; + artist?: string; + title?: string; + diff?: string; + version?: string; + beatmapCoverUrl?: string | null; + beatmapUrl?: string | null; + profileUrl?: string | null; + rank?: number | string; + pp?: number | string; + accuracy?: number | string; +}; + +const isValidHttpUrl = (url: string | undefined): boolean => { + if (!url) return false; + return url.startsWith('http://') || url.startsWith('https://'); +}; + +export const pushIdlePresenceFromConfig = async ( + params?: IdlePresenceParams, + cfg?: DiscordPresenceConfig +): Promise => { + const currentCfg = cfg ?? get(discordPresenceCustomConfig); + const context: PresenceVariableContext = { + username: params?.user?.name, + id: params?.user?.id ? String(params.user.id) : undefined, + rank: params?.rank, + pp: params?.pp, + accuracy: params?.accuracy, + version: params?.version, + profileUrl: params?.user?.id ? `https://ez-pp.farm/u/${params.user.id}` : 'https://ez-pp.farm', + }; + + const details = interpolatePresenceString(currentCfg.idleDetails, context); + const state = interpolatePresenceString(currentCfg.idleState, context); + const largeImageKey = currentCfg.idleLargeImageKey || 'ezppfarm'; + const largeImageText = interpolatePresenceString(currentCfg.idleLargeImageText, context); + + let smallImageKey: string | null = null; + let smallImageText: string | null = null; + if (currentCfg.idleShowUserAvatar && params?.user?.id) { + smallImageKey = `https://a.ez-pp.farm/${params.user.id}`; + smallImageText = params.user.name ?? 'Player'; + } + + let button1: { label: string; url: string } | null = null; + if (currentCfg.idleButton1.enabled && currentCfg.idleButton1.label.trim()) { + const rawUrl = interpolatePresenceString(currentCfg.idleButton1.url, context); + if (isValidHttpUrl(rawUrl)) { + button1 = { + label: interpolatePresenceString(currentCfg.idleButton1.label, context), + url: rawUrl, + }; + } + } + + let button2: { label: string; url: string } | null = null; + if (currentCfg.button2.enabled && currentCfg.button2.label.trim()) { + const rawUrl = interpolatePresenceString(currentCfg.button2.url, context); + if (isValidHttpUrl(rawUrl)) { + button2 = { + label: interpolatePresenceString(currentCfg.button2.label, context), + url: rawUrl, + }; + } + } + + try { + await updateActivity({ + state, + details, + largeImageKey, + largeImageText, + smallImageKey, + smallImageText, + button1, + button2, + showTimestamp: currentCfg.idleShowTime, + }); + } catch (err) { + console.warn('Failed to push idle Discord presence:', err); + } +}; + +export const pushPlayingPresenceFromConfig = async ( + params: PlayingPresenceParams, + cfg?: DiscordPresenceConfig +): Promise => { + const currentCfg = cfg ?? get(discordPresenceCustomConfig); + const context: PresenceVariableContext = { + username: params.user?.name, + id: params.user?.id ? String(params.user.id) : undefined, + rank: params.rank, + pp: params.pp, + accuracy: params.accuracy, + mode: params.mode, + action: params.action, + beatmap: params.beatmap, + artist: params.artist, + title: params.title, + diff: params.diff, + version: params.version, + beatmapUrl: params.beatmapUrl ?? 'https://ez-pp.farm', + profileUrl: + params.profileUrl ?? + (params.user?.id ? `https://ez-pp.farm/u/${params.user.id}` : 'https://ez-pp.farm'), + }; + + const details = interpolatePresenceString(currentCfg.playingDetails, context); + const state = interpolatePresenceString(currentCfg.playingState, context); + + let largeImageKey = currentCfg.playingLargeImageKey || 'ezppfarm'; + if (currentCfg.playingShowBeatmapCover && params.beatmapCoverUrl) { + largeImageKey = params.beatmapCoverUrl; + } + const largeImageText = interpolatePresenceString(currentCfg.playingLargeImageText, context); + + let smallImageKey: string | null = null; + let smallImageText: string | null = null; + if (currentCfg.playingShowUserAvatar && params.user?.id) { + smallImageKey = `https://a.ez-pp.farm/${params.user.id}`; + smallImageText = interpolatePresenceString(currentCfg.playingSmallImageText, context); + } + + let button1: { label: string; url: string } | null = null; + if (currentCfg.playingButton1.enabled && currentCfg.playingButton1.label.trim()) { + const rawUrl = interpolatePresenceString(currentCfg.playingButton1.url, context); + if (isValidHttpUrl(rawUrl)) { + button1 = { + label: interpolatePresenceString(currentCfg.playingButton1.label, context), + url: rawUrl, + }; + } + } + + let button2: { label: string; url: string } | null = null; + if (currentCfg.button2.enabled && currentCfg.button2.label.trim()) { + const rawUrl = interpolatePresenceString(currentCfg.button2.url, context); + if (isValidHttpUrl(rawUrl)) { + button2 = { + label: interpolatePresenceString(currentCfg.button2.label, context), + url: rawUrl, + }; + } + } + + try { + await updateActivity({ + state, + details, + largeImageKey, + largeImageText, + smallImageKey, + smallImageText, + button1, + button2, + showTimestamp: currentCfg.playingShowTime, + }); + } catch (err) { + console.warn('Failed to push playing Discord presence:', err); + } +}; diff --git a/src/lib/presence.ts b/src/lib/presence.ts index 996e43a..a0580bb 100644 --- a/src/lib/presence.ts +++ b/src/lib/presence.ts @@ -1,7 +1,29 @@ import { invoke } from '@tauri-apps/api/core'; +export type PresenceButtonPayload = { + label: string; + url: string; +}; + +export type PresenceActivityPayload = { + state?: string; + details?: string; + largeImageKey?: string; + largeImageText?: string; + smallImageKey?: string | null; + smallImageText?: string | null; + button1?: PresenceButtonPayload | null; + button2?: PresenceButtonPayload | null; + showTimestamp?: boolean; +}; + export const connect = async () => await invoke('presence_connect'); export const disconnect = async () => await invoke('presence_disconnect'); +export const isConnected = async () => await invoke('presence_is_connected'); + +export const updateActivity = async (activity: PresenceActivityPayload) => + await invoke('presence_update_activity', { activity }); + export const updateStatus = async (status: { state: string; details: string; @@ -14,8 +36,9 @@ export const updateStatus = async (status: { largeImageKey: status.largeImageKey, }, }); + export const updateUser = async (user: { username: string; id?: string | null }) => await invoke('presence_update_user', { user: { username: user.username, id: user.id } }); + export const updateButton = async (button: { text?: string; url?: string }) => - await invoke('presence_update_button', { button: button }); -export const isConnected = async () => await invoke('presence_is_connected'); + await invoke('presence_update_button', { button }); diff --git a/src/lib/themeData.ts b/src/lib/themeData.ts index f303b61..22b33be 100644 --- a/src/lib/themeData.ts +++ b/src/lib/themeData.ts @@ -82,12 +82,12 @@ export const themeGlobalData = derived( } ); -const formatInteger = (value: number) => Math.round(value).toLocaleString('en-US'); +export const formatInteger = (value: number) => Math.round(value).toLocaleString('en-US'); -const formatAccuracy = (value: number) => +export const formatAccuracy = (value: number) => `${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`; -const formatPlaytime = (seconds: number) => { +export const formatPlaytime = (seconds: number) => { const totalMinutes = Math.max(0, Math.round(seconds / 60)); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; @@ -95,54 +95,155 @@ const formatPlaytime = (seconds: number) => { return `${minutes}m`; }; -const formatDate = (date: Date) => +export const formatDate = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date); -/** Current value for a data layer field. Missing values render as an empty string. */ -export const resolveThemeDataValue = (field: ThemeV2DataField, context: ThemeDataContext) => { - const { global, theme, screen, mode = 'osu', now = new Date() } = context; - const stats = global.user.stats[mode]; - +/** + * Realistic sample values displayed in the theme editor and preview thumbnails + * when live launcher data is empty or unavailable (e.g. logged out or offline). + */ +export const getThemeDataSampleValue = ( + field: ThemeV2DataField, + mode: ThemeV2DataMode = 'osu' +): string => { switch (field) { case 'user.name': - return global.user.name; + return 'Player'; case 'user.country': - return global.user.country.toUpperCase(); + return 'US'; case 'user.pp': - return stats ? formatInteger(stats.pp) : ''; + return mode === 'catch' + ? '6,280' + : mode === 'taiko' + ? '5,140' + : mode === 'mania' + ? '8,920' + : '7,420'; case 'user.rank': - return stats && stats.rank > 0 ? formatInteger(stats.rank) : ''; + return mode === 'catch' + ? '840' + : mode === 'taiko' + ? '2,150' + : mode === 'mania' + ? '412' + : '1,337'; case 'user.plays': - return stats ? formatInteger(stats.plays) : ''; + return mode === 'catch' + ? '18,500' + : mode === 'taiko' + ? '12,300' + : mode === 'mania' + ? '35,800' + : '42,100'; case 'user.accuracy': - return stats ? formatAccuracy(stats.accuracy) : ''; + return '98.75%'; case 'user.playtime': - return stats ? formatPlaytime(stats.playtime) : ''; + return '124h 30m'; case 'launcher.version': - return global.launcher.version; + return ''; case 'server.friends': - return global.launcher.friends === undefined ? '' : formatInteger(global.launcher.friends); + return '12'; case 'osu.build': - return global.osu.build ?? ''; + return 'b20241030'; case 'osu.stream': - return global.osu.stream ?? ''; + return 'Stable40'; case 'server.ping': - return global.server.ping === undefined ? '' : `${Math.round(global.server.ping)} ms`; + return '24 ms'; case 'theme.name': - return theme.name; + return 'Sample Theme'; case 'theme.author': - return theme.author; + return 'Theme Creator'; case 'theme.version': - return theme.version; + return '1.0.0'; case 'screen': - return screen ? screen.charAt(0).toUpperCase() + screen.slice(1) : ''; + return 'Home'; case 'clock.time': - return String(themeClockValues(now).time ?? ''); + return '14:30:00'; case 'clock.time12': - return String(themeClockValues(now).time12 ?? ''); + return '2:30:00 PM'; case 'clock.date': - return formatDate(now); + return formatDate(new Date()); default: return ''; } }; + +/** Current value for a data layer field. Missing values render as an empty string. */ +export const resolveThemeDataValue = ( + field: ThemeV2DataField, + context: ThemeDataContext, + options?: { preview?: boolean } +) => { + const { global, theme, screen, mode = 'osu', now = new Date() } = context; + const stats = global.user.stats[mode]; + + let value = ''; + switch (field) { + case 'user.name': + value = global.user.name; + break; + case 'user.country': + value = global.user.country ? global.user.country.toUpperCase() : ''; + break; + case 'user.pp': + value = stats ? formatInteger(stats.pp) : ''; + break; + case 'user.rank': + value = stats && stats.rank > 0 ? formatInteger(stats.rank) : ''; + break; + case 'user.plays': + value = stats ? formatInteger(stats.plays) : ''; + break; + case 'user.accuracy': + value = stats ? formatAccuracy(stats.accuracy) : ''; + break; + case 'user.playtime': + value = stats ? formatPlaytime(stats.playtime) : ''; + break; + case 'launcher.version': + value = global.launcher.version; + break; + case 'server.friends': + value = global.launcher.friends === undefined ? '' : formatInteger(global.launcher.friends); + break; + case 'osu.build': + value = global.osu.build ?? ''; + break; + case 'osu.stream': + value = global.osu.stream ?? ''; + break; + case 'server.ping': + value = global.server.ping === undefined ? '' : `${Math.round(global.server.ping)} ms`; + break; + case 'theme.name': + value = theme.name; + break; + case 'theme.author': + value = theme.author; + break; + case 'theme.version': + value = theme.version; + break; + case 'screen': + value = screen ? screen.charAt(0).toUpperCase() + screen.slice(1) : ''; + break; + case 'clock.time': + value = String(themeClockValues(now).time ?? ''); + break; + case 'clock.time12': + value = String(themeClockValues(now).time12 ?? ''); + break; + case 'clock.date': + value = formatDate(now); + break; + default: + value = ''; + break; + } + + if (options?.preview && (!value || value.trim() === '')) { + return getThemeDataSampleValue(field, mode); + } + + return value; +}; diff --git a/src/lib/themeIconCatalog.ts b/src/lib/themeIconCatalog.ts new file mode 100644 index 0000000..7396374 --- /dev/null +++ b/src/lib/themeIconCatalog.ts @@ -0,0 +1,206 @@ +import { THEME_ICON_BODIES, type ThemeIconBody } from './themeIconData'; +import { + DEFAULT_THEME_ICON_SET, + THEME_ICON_NAMES, + type ThemeIconName, + type ThemeIconSet, +} from './themeIcons'; +import lucideCollection from '@iconify-json/lucide/icons.json'; +import phCollection from '@iconify-json/ph/icons.json'; +import tablerCollection from '@iconify-json/tabler/icons.json'; + +export type { ThemeIconBody }; + +export type IconPackKey = 'launcher' | 'lucide' | 'phosphor' | 'tabler'; + +export type ThemeIconCatalogItem = { + id: string; + name: string; + pack: IconPackKey; + packLabel: string; +}; + +type IconifyRawCollection = { + width?: number; + height?: number; + icons: Record; + aliases?: Record; +}; + +const collections: Record<'lucide' | 'ph' | 'tabler', IconifyRawCollection> = { + lucide: lucideCollection as unknown as IconifyRawCollection, + ph: phCollection as unknown as IconifyRawCollection, + tabler: tablerCollection as unknown as IconifyRawCollection, +}; + +const resolveFromCollection = ( + collection: IconifyRawCollection, + id: string +): ThemeIconBody | undefined => { + const direct = collection.icons[id]; + if (direct) { + return { + width: direct.width ?? collection.width ?? 24, + height: direct.height ?? collection.height ?? 24, + body: direct.body, + }; + } + + const alias = collection.aliases?.[id]; + if (alias) { + const parent = collection.icons[alias.parent]; + if (parent) { + return { + width: parent.width ?? collection.width ?? 24, + height: parent.height ?? collection.height ?? 24, + body: parent.body, + }; + } + } + + return undefined; +}; + +const FALLBACK_ICON: ThemeIconBody = { + width: 24, + height: 24, + body: '', +}; + +// Cache for quickly resolving icon bodies +const bodyCache = new Map(); + +/** + * Resolve any icon by name (semantic launcher name, prefixed like `lucide:bell`, + * `ph:sword`, `tabler:star`, or unprefixed matching an active/fallback pack). + */ +export function getThemeIconBody( + name: string | undefined | null, + set: ThemeIconSet = DEFAULT_THEME_ICON_SET +): ThemeIconBody { + if (!name || typeof name !== 'string') { + return THEME_ICON_BODIES[set]?.home ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET].home; + } + + const cacheKey = `${set}:${name}`; + const cached = bodyCache.get(cacheKey); + if (cached) return cached; + + // 1. Check if semantic launcher icon in THEME_ICON_BODIES + const semantic = (THEME_ICON_BODIES[set] as Record | undefined)?.[name]; + if (semantic) { + bodyCache.set(cacheKey, semantic); + return semantic; + } + const semanticDefault = ( + THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET] as Record | undefined + )?.[name]; + if (semanticDefault) { + bodyCache.set(cacheKey, semanticDefault); + return semanticDefault; + } + + // 2. Check for pack prefix like "lucide:xxx", "ph:xxx" / "phosphor:xxx", "tabler:xxx" + if (name.includes(':')) { + const colonIdx = name.indexOf(':'); + const prefix = name.slice(0, colonIdx).toLowerCase(); + const iconId = name.slice(colonIdx + 1); + + const packCol = + prefix === 'lucide' + ? collections.lucide + : prefix === 'ph' || prefix === 'phosphor' + ? collections.ph + : prefix === 'tabler' + ? collections.tabler + : undefined; + + if (packCol) { + const resolved = resolveFromCollection(packCol, iconId); + if (resolved) { + bodyCache.set(cacheKey, resolved); + return resolved; + } + } + } + + // 3. Try current set collection + const currentCollectionKey = set === 'phosphor' ? 'ph' : set === 'tabler' ? 'tabler' : 'lucide'; + const directCurrent = resolveFromCollection(collections[currentCollectionKey], name); + if (directCurrent) { + bodyCache.set(cacheKey, directCurrent); + return directCurrent; + } + + // 4. Try other collections (lucide, ph, tabler) + const lucideResolved = resolveFromCollection(collections.lucide, name); + if (lucideResolved) { + bodyCache.set(cacheKey, lucideResolved); + return lucideResolved; + } + const phResolved = resolveFromCollection(collections.ph, name); + if (phResolved) { + bodyCache.set(cacheKey, phResolved); + return phResolved; + } + const tablerResolved = resolveFromCollection(collections.tabler, name); + if (tablerResolved) { + bodyCache.set(cacheKey, tablerResolved); + return tablerResolved; + } + + // Fallback icon + return FALLBACK_ICON; +} + +// Cached full catalog +let fullCatalog: ThemeIconCatalogItem[] | null = null; + +export function getFullIconCatalog(): ThemeIconCatalogItem[] { + if (fullCatalog) return fullCatalog; + + const items: ThemeIconCatalogItem[] = []; + + // 1. Semantic Launcher icons + for (const name of THEME_ICON_NAMES) { + items.push({ + id: name, + name, + pack: 'launcher', + packLabel: 'Launcher', + }); + } + + // 2. Lucide icons + for (const name of Object.keys(collections.lucide.icons)) { + items.push({ + id: `lucide:${name}`, + name, + pack: 'lucide', + packLabel: 'Lucide', + }); + } + + // 3. Phosphor icons + for (const name of Object.keys(collections.ph.icons)) { + items.push({ + id: `ph:${name}`, + name, + pack: 'phosphor', + packLabel: 'Phosphor', + }); + } + + // 4. Tabler icons + for (const name of Object.keys(collections.tabler.icons)) { + items.push({ + id: `tabler:${name}`, + name, + pack: 'tabler', + packLabel: 'Tabler', + }); + } + + fullCatalog = items; + return items; +} diff --git a/src/lib/themeIcons.ts b/src/lib/themeIcons.ts index d4b3b0f..116adab 100644 --- a/src/lib/themeIcons.ts +++ b/src/lib/themeIcons.ts @@ -76,12 +76,12 @@ export const THEME_ICON_NAMES = [ 'volume-off', ] as const; -export type ThemeIconName = (typeof THEME_ICON_NAMES)[number]; +export type ThemeIconName = (typeof THEME_ICON_NAMES)[number] | (string & {}); const ICON_NAME_SET = new Set(THEME_ICON_NAMES); export const isThemeIconName = (value: unknown): value is ThemeIconName => - typeof value === 'string' && ICON_NAME_SET.has(value); + typeof value === 'string' && value.trim().length > 0 && value.length <= 120; /** A small representative row used by the editor's live icon preview. */ export const THEME_ICON_PREVIEW_NAMES: ThemeIconName[] = [ diff --git a/src/lib/themePreview.ts b/src/lib/themePreview.ts index b067970..372b2eb 100644 --- a/src/lib/themePreview.ts +++ b/src/lib/themePreview.ts @@ -1,4 +1,5 @@ -import { resolveThemeDataValue, themeGlobalData } from './themeData'; +import { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from './themeData'; +import { getThemeIconBody } from './themeIconCatalog'; import { THEME_ICON_BODIES } from './themeIconData'; import { themeArcGeometry, @@ -309,9 +310,7 @@ const loadImageAsset = (context: PreviewContext, path: string) => { const loadThemeIcon = (context: PreviewContext, name: string, color: string) => { const set = context.definition.iconSet ?? 'lucide'; - const icon = - THEME_ICON_BODIES[set]?.[name as keyof (typeof THEME_ICON_BODIES)[typeof set]] ?? - THEME_ICON_BODIES.lucide[name as keyof (typeof THEME_ICON_BODIES)['lucide']]; + const icon = getThemeIconBody(name, set); if (!icon) return Promise.resolve(undefined); const key = `${set}|${name}|${color}`; @@ -948,15 +947,6 @@ const drawSidebar = (context: PreviewContext, logo?: LoadedMedia) => { ctx.restore(); } - fillRoundedRect(ctx, centerX - 14, 75, 28, 13, 6.5, accent); - ctx.save(); - ctx.fillStyle = accentText; - ctx.font = `600 8px ${quoteFamily('Sora Variable')}, ui-sans-serif, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('3.2.7', centerX, 82); - ctx.restore(); - const navIcons = ['home', 'palette', 'sliders'] as const; navIcons.forEach((icon, index) => { const y = 99 + index * 60; @@ -1372,8 +1362,18 @@ const drawDataElement = (context: PreviewContext, element: ThemeV2DataElement) = screen: 'home', mode: element.mode, }); + const fallback = element.defaultValue ?? ''; + const sample = getThemeDataSampleValue(element.field, element.mode); + const value = + raw && raw.trim() !== '' ? raw : fallback && fallback.trim() !== '' ? fallback : sample; const template = element.text?.trim() ? element.text : '{value}'; - const text = interpolateThemeText(template.replaceAll('{value}', raw), context.variables); + const text = interpolateThemeText( + template + .replaceAll('{value}', value) + .replaceAll('{defaultValue}', fallback) + .replaceAll('{fallback}', fallback), + context.variables + ); drawTextElement(context, { ...element, type: 'text', text } as ThemeV2TextElement); }; diff --git a/src/lib/themeV2.ts b/src/lib/themeV2.ts index 4195394..0148fe5 100644 --- a/src/lib/themeV2.ts +++ b/src/lib/themeV2.ts @@ -141,6 +141,10 @@ export type ThemeV2Group = ThemeV2Transform & { mask?: ThemeV2Mask; /** Space-separated CSS classes inherited by every layer in the group. */ cssClasses?: string; + /** Smoothly morph text and number changes using torph across layers in this group. */ + morph?: boolean; + /** Morph animation duration in milliseconds (default 400). */ + morphDuration?: number; }; /** A shape or image used to clip a layer or group. */ @@ -185,6 +189,10 @@ export type ThemeV2ElementBase = ThemeV2Transform & { mask?: ThemeV2Mask; /** Space-separated CSS classes added to the rendered layer wrapper. */ cssClasses?: string; + /** Smoothly morph text and number changes using torph when content changes. */ + morph?: boolean; + /** Morph animation duration in milliseconds (default 400). */ + morphDuration?: number; }; export type ThemeV2TextDecoration = @@ -263,6 +271,8 @@ export type ThemeV2DataElement = ThemeV2ElementBase & mode?: ThemeV2DataMode; /** Template for the value; `{value}` is replaced. Defaults to `{value}`. */ text: string; + /** Fallback value when live data is missing or empty (e.g. when logged out). */ + defaultValue?: string; }; export type ThemeV2ImageElement = ThemeV2ElementBase & { @@ -755,6 +765,11 @@ const normalizeGroups = (raw: unknown): ThemeV2Group[] => { const filters = normalizeFilters(source.filters); const cssClasses = themeClassNames(source.cssClasses); const mask = normalizeMask(source.mask); + const morph = typeof source.morph === 'boolean' ? source.morph : undefined; + const morphDuration = + typeof source.morphDuration === 'number' && Number.isFinite(source.morphDuration) + ? clamp(source.morphDuration, 400, 50, 5000) + : undefined; const group: ThemeV2Group = { id, name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined, @@ -769,6 +784,8 @@ const normalizeGroups = (raw: unknown): ThemeV2Group[] => { ...(filters ? { filters } : {}), ...(mask ? { mask } : {}), ...(cssClasses ? { cssClasses } : {}), + ...(morph !== undefined ? { morph } : {}), + ...(morphDuration !== undefined ? { morphDuration } : {}), }; groups.push(group); }); @@ -899,6 +916,11 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin 'text' ); const cssClasses = themeClassNames(source.cssClasses); + const morph = typeof source.morph === 'boolean' ? source.morph : undefined; + const morphDuration = + typeof source.morphDuration === 'number' && Number.isFinite(source.morphDuration) + ? clamp(source.morphDuration, 400, 50, 5000) + : undefined; const base = { id: text(source.id, `element_${index + 1}`).slice(0, 64), name: typeof source.name === 'string' ? source.name.slice(0, 80) : undefined, @@ -915,6 +937,8 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin filters: normalizeFilters(source.filters), mask: normalizeMask(source.mask), ...(cssClasses ? { cssClasses } : {}), + ...(morph !== undefined ? { morph } : {}), + ...(morphDuration !== undefined ? { morphDuration } : {}), }; if (type === 'text') { @@ -933,6 +957,9 @@ const normalizeElement = (raw: unknown, index: number): ThemeV2Element | undefin field: oneOf(source.field, DATA_FIELD_VALUES, 'user.name' as ThemeV2DataField), mode: oneOf(source.mode, ['osu', 'taiko', 'catch', 'mania'] as const, 'osu'), text: typeof source.text === 'string' ? source.text.slice(0, 400) : '{value}', + ...(typeof source.defaultValue === 'string' + ? { defaultValue: source.defaultValue.slice(0, 200) } + : {}), ...normalizeTextStyle(source), }; } @@ -1445,6 +1472,7 @@ export const createThemeV2Element = (type: ThemeV2Element['type'], index = 0): T type, field: 'user.name', text: 'Welcome, {value}!', + defaultValue: 'Guest', fontSize: 26, width: 420, height: 60, @@ -2258,10 +2286,11 @@ export const themeV2Css = ( assetOverrides: Record = {} ) => { const accent = definition.accentColor; + const accentText = accent ? readableTextColor(accent) : '#ffffff'; // `.dark` also defines the primary variables, so the override needs a // selector that is at least as specific as `html.dark`. const accentCss = accent - ? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${readableTextColor(accent)};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${readableTextColor(accent)};}` + ? `:root,:root.dark,:root.light{--primary:${accent};--primary-foreground:${accentText};--accent:${accent};--accent-foreground:${accentText};--ring:${accent};--color-primary:${accent};--color-primary-500:${accent};--color-primary-foreground:${accentText};--color-accent:${accent};--color-accent-foreground:${accentText};}` : ''; const body = definition.typography?.body?.trim(); const heading = definition.typography?.heading?.trim(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2552d69..83a3ac1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,7 @@