feat: add discord rich presence editor window and interactive embed preview

This commit is contained in:
HorizonCode committed 2026-10-31 17:59:07 +01:00
1 parent e3abea8d83
commit 386c829455
30 files changed
+4102 -263

No files matched your search

+2 -1
View File
@@ -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",
+143
View File
@@ -1087,6 +1087,35 @@ pub struct PresenceButton {
url: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PresenceActivityPayload {
pub state: Option<String>,
pub details: Option<String>,
pub large_image_key: Option<String>,
pub large_image_text: Option<String>,
pub small_image_key: Option<Option<String>>,
pub small_image_text: Option<Option<String>>,
pub button1: Option<Option<presence::PresenceButton>>,
pub button2: Option<Option<presence::PresenceButton>>,
pub show_timestamp: Option<bool>,
}
#[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<String>) ->
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<String>) ->
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<ThemeEditorContext, String> {
+11 -5
View File
@@ -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,
+76 -11
View File
@@ -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<String>,
pub small_image_text: Option<String>,
pub dynamic_button: Option<PresenceButton>,
pub secondary_button: Option<PresenceButton>,
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<Option<String>>,
small_image_text: Option<Option<String>>,
button1: Option<Option<PresenceButton>>,
button2: Option<Option<PresenceButton>>,
show_timestamp: Option<bool>,
) {
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();
+4 -4
View File
@@ -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);
@@ -345,7 +345,7 @@
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
onclick={copyReport}
>
<ClipboardCopy class="size-4" />
@@ -354,7 +354,7 @@
<Button
variant="outline"
size="sm"
class="border-theme-800 bg-theme-950"
class="border-theme-800 bg-theme-950 hover:border-primary hover:bg-primary hover:text-primary-foreground transition-colors"
onclick={runChecks}
disabled={checking}
>
File diff suppressed because it is too large. Load diff
@@ -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<ThemeEditorGlobalSyncPayload>('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<string>('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');
}
</script>
@@ -5379,6 +5444,10 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<ThemeElementInspector
bind:element={definition.elements[selectedIndex]}
iconSet={definition.iconSet}
themeName={definition.name}
themeAuthor={definition.author}
themeVersion={definition.version}
{fontOptions}
{elements}
{behaviors}
@@ -2,6 +2,7 @@
import ThemeComponentEditor from '$lib/components/theme/ThemeComponentEditor.svelte';
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
import ThemeIconPicker from '$lib/components/theme/ThemeIconPicker.svelte';
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
import ThemeTextStyleControls from '$lib/components/theme/ThemeTextStyleControls.svelte';
@@ -14,11 +15,13 @@
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 { getThemeDataSampleValue, resolveThemeDataValue, themeGlobalData } from '$lib/themeData';
import type { ThemeFontChoice } from '$lib/themeFonts';
import { THEME_ICON_NAMES } from '$lib/themeIcons';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import {
THEME_DATA_FIELDS,
THEME_PARTICLE_PRESETS,
themeGroupAncestors,
type ThemeV2Behavior,
type ThemeV2BehaviorTarget,
type ThemeV2Element,
@@ -48,6 +51,10 @@
elements,
behaviors,
groups,
iconSet = DEFAULT_THEME_ICON_SET,
themeName = 'My Theme',
themeAuthor = '',
themeVersion = '1.0.0',
variables = [],
focusToken = 0,
onreplace,
@@ -61,6 +68,10 @@
elements: ThemeV2Element[];
behaviors: ThemeV2Behavior[];
groups: ThemeV2Group[];
iconSet?: ThemeIconSet;
themeName?: string;
themeAuthor?: string;
themeVersion?: string;
variables?: string[];
focusToken?: number;
onreplace: (kind: 'image' | 'video' | 'lottie') => 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;
});
</script>
<div class="space-y-3 p-3">
@@ -237,6 +312,63 @@
Interactive
</Label>
</div>
<div
class="flex items-center gap-1.5"
title="Smoothly morph text and number changes using Torph."
>
<Checkbox
id="element-morph"
checked={element.morph === true || (element.morph !== false && parentGroupMorphs)}
onCheckedChange={(checked) => {
if (parentGroupMorphs) {
element.morph = checked === true ? true : false;
} else {
element.morph = checked === true;
}
}}
/>
<Label for="element-morph" class="text-[10px] font-normal text-muted-foreground">
Morph text (Torph)
</Label>
{#if parentGroupMorphs && element.morph !== false}
<span class="text-[9px] text-muted-foreground" title="Inherited from parent group"
>(group)</span
>
{/if}
{#if parentGroupMorphs && element.morph !== undefined}
<button
type="button"
class="text-[9px] text-primary hover:underline ml-0.5"
onclick={() => delete element.morph}
title="Reset to inherit from group"
>
reset
</button>
{/if}
</div>
{#if element.morph === true || (element.morph !== false && parentGroupMorphs)}
<div class="w-full space-y-1 pt-1">
<div class="flex items-center justify-between">
<Label for="element-morph-duration" class={label}>Morph duration</Label>
<span class="text-[10px] text-muted-foreground">
{element.morphDuration ?? (parentGroupMorphDuration || 400)}ms
</span>
</div>
<Input
id="element-morph-duration"
class={field}
type="number"
min="50"
max="3000"
step="50"
value={element.morphDuration ?? (parentGroupMorphDuration || 400)}
oninput={(event) => {
const val = Number((event.currentTarget as HTMLInputElement).value);
element.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
}}
/>
</div>
{/if}
</ThemeSection>
<ThemeCssClasses
@@ -259,7 +391,7 @@
{#if element.type === 'text' || element.type === 'data'}
<ThemeSection title={element.type === 'data' ? 'Data' : 'Text'} contentClass="space-y-3 pt-2.5">
{#if element.type === 'data'}
<div class="space-y-1">
<div class="space-y-1.5">
<Label for="element-data-field" class={label}>Value</Label>
<Select.Root
type="single"
@@ -272,8 +404,7 @@
id="element-data-field"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{THEME_DATA_FIELDS.find((field) => field.value === element.field)?.label ??
element.field}
{currentFieldLabel}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_DATA_FIELDS as field (field.value)}
@@ -281,6 +412,48 @@
{/each}
</Select.Content>
</Select.Root>
<!-- Current Evaluated Value Display -->
<div class="rounded-md border border-theme-800/80 bg-theme-950/80 p-2 space-y-1">
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
<span class="font-medium">Selected Value ({currentFieldLabel})</span>
{#if currentRawValue && currentRawValue.trim() !== ''}
<span
class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400 border border-emerald-500/25"
>
Live value
</span>
{:else if element.defaultValue && element.defaultValue.trim() !== ''}
<span
class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-medium text-amber-400 border border-amber-500/25"
>
Default
</span>
{:else if currentSampleValue}
<span
class="rounded bg-sky-500/15 px-1.5 py-0.5 text-[9px] font-medium text-sky-400 border border-sky-500/25"
>
Sample preview
</span>
{:else}
<span
class="rounded bg-theme-800 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
>
Empty
</span>
{/if}
</div>
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-xs font-semibold text-theme-50">
{previewDisplayValue ? previewDisplayValue : '(empty)'}
</span>
{#if !currentRawValue && (!element.defaultValue || element.defaultValue.trim() === '') && currentSampleValue}
<span class="text-[9px] text-muted-foreground shrink-0"> (sample preview) </span>
{:else if !currentRawValue && element.defaultValue}
<span class="text-[9px] text-muted-foreground shrink-0"> (using default) </span>
{/if}
</div>
</div>
</div>
{#if element.field.startsWith('user.') && element.field !== 'user.name' && element.field !== 'user.country'}
<div class="space-y-1">
@@ -322,6 +495,26 @@
still work too.
</p>
</div>
<div class="space-y-1">
<Label for="element-data-default" class={label}>Default value</Label>
<Input
id="element-data-default"
class={field}
bind:value={element.defaultValue}
placeholder={element.field === 'user.name' ? 'e.g. Guest' : 'e.g. -'}
/>
<p class="text-[9px] leading-relaxed text-muted-foreground">
Fallback value used when the data is empty or unavailable (such as when logged out).
</p>
</div>
<div class="rounded-md border border-theme-800/60 bg-theme-900/40 p-2 space-y-0.5">
<div class="text-[9px] uppercase tracking-wide text-muted-foreground font-semibold">
Result Preview
</div>
<div class="truncate font-mono text-xs font-semibold text-primary">
{previewResult || '(empty)'}
</div>
</div>
{:else}
<div class="space-y-1">
<Label for="element-text" class={label}>Text</Label>
@@ -950,27 +1143,9 @@
<ThemeSection title="Icon" contentClass="space-y-3 pt-2.5">
<div class="space-y-1">
<Label for="element-icon" class={label}>Icon</Label>
<Select.Root
type="single"
value={element.icon}
onValueChange={(value) => {
if (value) element.icon = value as typeof element.icon;
}}
>
<Select.Trigger
id="element-icon"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.icon}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_ICON_NAMES as name (name)}
<Select.Item value={name} label={name} />
{/each}
</Select.Content>
</Select.Root>
<ThemeIconPicker bind:value={element.icon} {iconSet} />
<p class="text-[9px] leading-relaxed text-muted-foreground">
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.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
@@ -1033,25 +1208,7 @@
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-repeater-icon" class={label}>Icon</Label>
<Select.Root
type="single"
value={element.icon ?? 'music'}
onValueChange={(value) => {
if (value) element.icon = value as typeof element.icon;
}}
>
<Select.Trigger
id="element-repeater-icon"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.icon ?? 'music'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
{#each THEME_ICON_NAMES as name (name)}
<Select.Item value={name} label={name} />
{/each}
</Select.Content>
</Select.Root>
<ThemeIconPicker bind:value={element.icon} {iconSet} />
</div>
<div class="space-y-1">
<Label for="element-repeater-color" class={label}>Color</Label>
@@ -1439,26 +1596,7 @@
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label for="element-button-icon" class={label}>Icon</Label>
<Select.Root
type="single"
value={element.icon ?? 'none'}
onValueChange={(value) => {
element.icon = value && value !== 'none' ? (value as typeof element.icon) : undefined;
}}
>
<Select.Trigger
id="element-button-icon"
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
>
{element.icon ?? 'None'}
</Select.Trigger>
<Select.Content class="border-theme-800 bg-theme-950">
<Select.Item value="none" label="None" />
{#each THEME_ICON_NAMES as name (name)}
<Select.Item value={name} label={name} />
{/each}
</Select.Content>
</Select.Root>
<ThemeIconPicker bind:value={element.icon} {iconSet} allowNone />
</div>
<div class="space-y-1">
<Label for="element-button-radius" class={label}>Radius</Label>
@@ -114,6 +114,40 @@
/>
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
</div>
<div
class="flex items-center gap-1.5"
title="Smoothly morph text and number changes across layers in this group using Torph."
>
<Checkbox
id="group-morph"
checked={group.morph === true}
onCheckedChange={(checked) => (group.morph = checked === true)}
/>
<Label for="group-morph" class="text-[10px] font-normal text-muted-foreground">
Morph text (Torph)
</Label>
</div>
{#if group.morph}
<div class="w-full space-y-1 pt-1">
<div class="flex items-center justify-between">
<Label for="group-morph-duration" class={label}>Morph duration</Label>
<span class="text-[10px] text-muted-foreground">{group.morphDuration ?? 400}ms</span>
</div>
<Input
id="group-morph-duration"
class={field}
type="number"
min="50"
max="3000"
step="50"
value={group.morphDuration ?? 400}
oninput={(event) => {
const val = Number((event.currentTarget as HTMLInputElement).value);
group.morphDuration = Number.isFinite(val) && val > 0 ? val : 400;
}}
/>
</div>
{/if}
</ThemeSection>
<ThemeCssClasses
+2 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
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));
</script>
<svg
@@ -0,0 +1,393 @@
<script lang="ts">
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<IconPackKey | 'all'>('all');
let viewMode = $state<'grid' | 'list'>('grid');
let scrollContainer = $state<HTMLDivElement | null>(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';
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-800 bg-theme-950 px-2.5 text-xs text-theme-50 transition-colors hover:bg-theme-900/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{#if !value || value === 'none'}
<span class="flex items-center gap-2 text-muted-foreground truncate">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>{allowNone ? 'None' : placeholder}</span>
</span>
{:else}
<span class="flex items-center gap-2 min-w-0 truncate">
<span
class="flex size-5 shrink-0 items-center justify-center rounded bg-theme-900 border border-theme-800 text-theme-200"
>
<ThemeIcon name={value} set={iconSet} size={15} />
</span>
<span class="truncate font-mono text-xs text-theme-100">{displayName(value)}</span>
{#if displayPack(value)}
<span
class="rounded bg-theme-800/80 px-1 py-0.5 text-[8px] uppercase tracking-wide text-muted-foreground shrink-0"
>
{displayPack(value)}
</span>
{/if}
</span>
{/if}
<ChevronsUpDown class="size-3.5 shrink-0 text-muted-foreground opacity-60" />
</Popover.Trigger>
<Popover.Content
align="start"
side="bottom"
sideOffset={4}
class="w-[360px] max-w-[95vw] rounded-lg border border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
>
<!-- Search Bar -->
<div class="border-b border-theme-800/80 p-2">
<div class="relative flex items-center">
<Search class="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none" />
<Input
bind:value={searchQuery}
placeholder="Search 17,000+ icons..."
class="h-8 pl-8 pr-7 text-xs border-theme-800 bg-theme-900/60 placeholder:text-muted-foreground"
autofocus
/>
{#if searchQuery}
<button
type="button"
class="absolute right-2 text-muted-foreground hover:text-theme-100 transition-colors"
onclick={() => (searchQuery = '')}
>
<X class="size-3.5" />
</button>
{/if}
</div>
</div>
<!-- Pack Filter Tabs & View Toggle -->
<div
class="flex items-center justify-between gap-1 border-b border-theme-800/60 bg-theme-900/30 px-2 py-1.5"
>
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar py-0.5">
{#each packTabs as tab (tab.key)}
<button
type="button"
class="shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {selectedPack ===
tab.key
? 'bg-primary/20 text-primary border border-primary/40 font-semibold'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-200 border border-transparent'}"
onclick={() => (selectedPack = tab.key)}
>
{tab.label}
</button>
{/each}
</div>
<div class="flex items-center gap-0.5 shrink-0 pl-1 border-l border-theme-800/60">
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'grid'
? 'bg-theme-800 text-theme-100'
: ''}"
title="Grid view"
onclick={() => (viewMode = 'grid')}
>
<LayoutGrid class="size-3.5" />
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'list'
? 'bg-theme-800 text-theme-100'
: ''}"
title="List view"
onclick={() => (viewMode = 'list')}
>
<List class="size-3.5" />
</button>
</div>
</div>
<!-- Allow "None" option if requested -->
{#if allowNone}
<div class="border-b border-theme-800/50 px-2 py-1 bg-theme-900/20">
<button
type="button"
class="flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors {value ===
'none' || !value
? 'bg-primary/20 text-primary font-medium'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-100'}"
onclick={() => selectIcon('none')}
>
<span class="flex items-center gap-2">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>None (No icon)</span>
</span>
{#if value === 'none' || !value}
<Check class="size-3 text-primary" />
{/if}
</button>
</div>
{/if}
<!-- Virtual Scrolling List/Grid -->
<div
bind:this={scrollContainer}
class="overflow-y-auto px-2 py-1 select-none"
style="height: {containerHeight}px;"
onscroll={(e) => {
scrollTop = (e.currentTarget as HTMLElement).scrollTop;
}}
>
{#if filteredItems.length === 0}
<div
class="flex flex-col items-center justify-center py-12 text-center text-xs text-muted-foreground"
>
<p>No icons found</p>
{#if searchQuery}
<p class="text-[10px] text-muted-foreground/80 mt-0.5 font-mono">"{searchQuery}"</p>
{/if}
<Button
variant="ghost"
size="sm"
class="mt-3 h-7 text-[10px]"
onclick={() => {
searchQuery = '';
selectedPack = 'all';
}}
>
Clear filters
</Button>
</div>
{:else}
<div style="height: {totalHeight}px; position: relative;">
<div
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startIndex *
rowHeight}px);"
>
{#if viewMode === 'grid'}
{#each visibleRows as row (row.index)}
<div class="grid grid-cols-5 gap-1.5" style="height: {rowHeight}px;">
{#each row.items ?? [] as item (item.id)}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<button
type="button"
class="group relative flex flex-col items-center justify-center rounded-md p-1 border transition-all text-center
{isSelected
? 'bg-primary/20 border-primary text-primary shadow-xs'
: 'border-transparent bg-theme-900/40 hover:bg-theme-800/80 hover:border-theme-700 text-theme-200 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex size-5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={18} />
</div>
<span
class="mt-0.5 w-full truncate text-[9px] font-mono leading-tight opacity-75 group-hover:opacity-100"
>
{item.name}
</span>
{#if isSelected}
<div
class="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
></div>
{/if}
</button>
{/each}
</div>
{/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)}
<div style="height: {rowHeight}px;" class="flex items-center">
<button
type="button"
class="flex w-full h-7 items-center justify-between rounded px-2 text-xs transition-colors
{isSelected
? 'bg-primary/20 text-primary font-medium'
: 'text-theme-200 hover:bg-theme-800/70 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex size-4.5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={16} />
</div>
<span class="truncate font-mono text-[11px]">{item.name}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<span
class="text-[9px] uppercase px-1 py-0.2 rounded bg-theme-900/80 border border-theme-800/80 text-muted-foreground font-mono"
>
{item.packLabel}
</span>
{#if isSelected}
<Check class="size-3 text-primary" />
{/if}
</div>
</button>
</div>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer Stats Bar -->
<div
class="flex items-center justify-between border-t border-theme-800/70 bg-theme-900/40 px-3 py-1.5 text-[10px] text-muted-foreground"
>
<span>{filteredItems.length.toLocaleString()} icons</span>
<span class="text-[9px]"
>Theme pack: <span class="capitalize text-theme-200">{iconSet}</span></span
>
</div>
</Popover.Content>
</Popover.Root>
+11 -5
View File
@@ -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 @@
<div class="rounded-[1.1rem] bg-primary/30 p-1.5 ring-1 ring-inset ring-white/15">
<img class="pointer-events-none size-[43px]" src={Logo} alt="" draggable="false" />
</div>
<span
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
>
3.2.7
</span>
{#if variables.launcher_version !== undefined && variables.launcher_version !== ''}
<span
class="rounded-full bg-primary px-2 py-0 text-[8px] font-semibold text-primary-foreground"
>
{variables.launcher_version}
</span>
{/if}
<div
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
>
+169 -50
View File
@@ -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)}
<div
class="h-full w-full overflow-hidden"
style="display:flex;align-items:{element.verticalAlign === 'center'
@@ -1244,16 +1273,26 @@
? 'flex-end'
: 'flex-start'};text-align:{element.align ?? 'left'};"
>
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
>
{element.type === 'data'
? dataValue(element)
: interpolateThemeText(element.text, variables)}
</span>
{#if isMorphEnabled(element)}
<TextMorph
text={String(content ?? '')}
duration={morphDuration(element)}
as="span"
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
28}px;font-weight:{element.fontWeight ?? 600};line-height:{element.lineHeight ??
1.2};letter-spacing:{element.letterSpacing ??
0}px;white-space:pre-wrap;overflow-wrap:anywhere;{themeTextStyleCss(element)}"
>
{content}
</span>
{/if}
</div>
{:else if element.type === 'image'}
{#if element.tint}
@@ -1420,14 +1459,27 @@
/>
</svg>
{#if gaugeLabel(element)}
<span
class="pointer-events-none absolute text-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{gaugeLabel(element)}
</span>
{@const label = gaugeLabel(element)}
{#if isMorphEnabled(element)}
<TextMorph
text={String(label ?? '')}
duration={morphDuration(element)}
as="span"
class="pointer-events-none absolute text-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
/>
{:else}
<span
class="pointer-events-none absolute text-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{label}
</span>
{/if}
{/if}
</div>
{:else}
@@ -1444,14 +1496,27 @@
></div>
</div>
{#if gaugeLabel(element)}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{gaugeLabel(element)}
</span>
{@const label = gaugeLabel(element)}
{#if isMorphEnabled(element)}
<TextMorph
text={String(label ?? '')}
duration={morphDuration(element)}
as="span"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
/>
{:else}
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center"
style="color:{element.labelColor ?? '#ffffff'};font-family:{fontStack(
element.fontFamily
)};font-size:{element.fontSize ?? 20}px;font-weight:700;"
>
{label}
</span>
{/if}
{/if}
</div>
{/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)}
<TextMorph
text={String(interpolateThemeText(element.text, variables) ?? '')}
duration={morphDuration(element)}
as="span"
/>
{:else}
{interpolateThemeText(element.text, variables)}
{/if}
</span>
{/each}
</div>
@@ -1506,7 +1579,16 @@
{#if element.icon}
<ThemeIcon name={element.icon} set={iconSet} size="1.1em" />
{/if}
<span class="truncate">{interpolateThemeText(element.label, variables)}</span>
{#if isMorphEnabled(element)}
<TextMorph
text={String(interpolateThemeText(element.label, variables) ?? '')}
duration={morphDuration(element)}
as="span"
class="truncate"
/>
{:else}
<span class="truncate">{interpolateThemeText(element.label, variables)}</span>
{/if}
</button>
{:else if element.type === 'path'}
{@const gradientVector = themeSvgGradientVector(element.gradientAngle ?? 135)}
@@ -1646,21 +1728,46 @@
'#ff006e'};">{element.label}</span
>
{/if}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ??
700};line-height:1.05;color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeClockTime(element, now)}
</span>
{#if element.showDate}
{#if isMorphEnabled(element)}
<TextMorph
text={String(themeClockTime(element, now) ?? '')}
duration={morphDuration(element)}
as="span"
class="block"
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ??
700};line-height:1.05;color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
element
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
>{themeClockDate(now)}</span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ??
700};line-height:1.05;color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeClockTime(element, now)}
</span>
{/if}
{#if element.showDate}
{#if isMorphEnabled(element)}
<TextMorph
text={String(themeClockDate(now) ?? '')}
duration={morphDuration(element)}
as="span"
class="block"
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
element
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{clockCaptionSize(
element
)}px;font-weight:500;color:{element.accentColor ?? '#ff006e'};"
>{themeClockDate(now)}</span
>
{/if}
{/if}
</div>
{/if}
@@ -1673,13 +1780,25 @@
? 'flex-end'
: 'center'};text-align:{element.align ?? 'center'};"
>
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeCountdownText(element, clockNow)}
</span>
{#if isMorphEnabled(element)}
<TextMorph
text={String(themeCountdownText(element, clockNow) ?? '')}
duration={morphDuration(element)}
as="span"
class="block"
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
/>
{:else}
<span
style="font-family:{fontStack(element.fontFamily)};font-size:{element.fontSize ??
40}px;font-weight:{element.fontWeight ?? 700};color:{element.color ??
'#ffffff'};font-variant-numeric:tabular-nums;"
>
{themeCountdownText(element, clockNow)}
</span>
{/if}
</div>
{/if}
</div>
+22
View File
@@ -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,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Tooltip as TooltipPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: TooltipPrimitive.ArrowProps = $props();
</script>
<TooltipPrimitive.Arrow
bind:ref
data-slot="tooltip-arrow"
class={cn('text-popover fill-popover', className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import TooltipPortal from "./tooltip-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 6,
portalProps,
...restProps
}: TooltipPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
} = $props();
</script>
<TooltipPortal {...portalProps}>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border border-border px-3 py-1.5 text-xs shadow-md",
className
)}
{...restProps}
/>
</TooltipPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
</script>
<TooltipPrimitive.Portal {...restProps} />
@@ -0,0 +1,13 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let {
delayDuration = 100,
...restProps
}: TooltipPrimitive.ProviderProps = $props();
</script>
<TooltipPrimitive.Provider
{delayDuration}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Tooltip as TooltipPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger
bind:ref
data-slot="tooltip-trigger"
class={cn("", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
</script>
<TooltipPrimitive.Root bind:open {...restProps} />
+417
View File
@@ -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<DiscordPresenceConfig>;
};
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<DiscordPresenceConfig>(
DEFAULT_DISCORD_PRESENCE_CONFIG
);
export const loadDiscordPresenceConfig = async (): Promise<DiscordPresenceConfig> => {
try {
const raw = await config
.value<Partial<DiscordPresenceConfig>>('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<void> => {
discordPresenceCustomConfig.set(newConfig);
try {
await config.value<DiscordPresenceConfig>('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<void> => {
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<void> => {
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);
}
};
+25 -2
View File
@@ -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<boolean>('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<boolean>('presence_is_connected');
await invoke('presence_update_button', { button });
+129 -28
View File
@@ -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;
};
+206
View File
@@ -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<string, { body: string; width?: number; height?: number }>;
aliases?: Record<string, { parent: string }>;
};
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: '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 8v4m0 4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
};
// Cache for quickly resolving icon bodies
const bodyCache = new Map<string, ThemeIconBody>();
/**
* 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<string, ThemeIconBody> | undefined)?.[name];
if (semantic) {
bodyCache.set(cacheKey, semantic);
return semantic;
}
const semanticDefault = (
THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET] as Record<string, ThemeIconBody> | 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;
}
+2 -2
View File
@@ -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<string>(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[] = [
+14 -14
View File
@@ -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);
};
+30 -1
View File
@@ -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<string, string> = {}
) => {
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();
+144 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import Logo from '$assets/logo.png';
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
import ThemeEditorWindow from '$lib/components/theme/ThemeEditorWindow.svelte';
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -11,6 +12,7 @@
import Titlebar from '$lib/components/ui/titlebar/titlebar.svelte';
import { config } from '$lib/config';
import { Config } from '$lib/config_old';
import { currentUserInfo } from '$lib/data';
import {
active_custom_theme,
currentLoadingInfo,
@@ -20,10 +22,14 @@
discordPresence,
firstStartup,
launcherVersion,
onlineFriends,
openTabletDriverEnabled,
openTabletDriverPath,
osuBuild,
osuStream,
platform,
presenceLoading,
serverPing,
setupValues,
theme_reload_token,
trackingEnabled,
@@ -41,6 +47,7 @@
import { isThemeTrusted } from '$lib/themeTrust';
import { themeV2Css } from '$lib/themeV2';
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
import { currentUser } from '$lib/userAuthentication';
import {
cursorSmoothening,
customCursor,
@@ -50,19 +57,28 @@
} from '$lib/userSettings';
import { trackMediaContainer } from '$lib/utils';
import '../app.css';
import { listen } from '@tauri-apps/api/event';
import { Sparkles } from '@lucide/svelte';
import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Buffer } from 'buffer';
import { SemVer } from 'semver';
import { sileo } from 'sileo';
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
let { children } = $props();
let unlistenEditorState: (() => void) | undefined;
onDestroy(() => unlistenEditorState?.());
let unlistenRequestEditorData: (() => void) | undefined;
let unlistenPresenceEditorState: (() => void) | undefined;
onDestroy(() => {
unlistenEditorState?.();
unlistenRequestEditorData?.();
unlistenPresenceEditorState?.();
});
if (browser) initLogging();
@@ -75,7 +91,18 @@
}
})();
const presenceEditorWindow = (() => {
if (!browser) return false;
try {
return getCurrentWindow().label === 'presence-editor';
} catch {
return false;
}
})();
let unsupported_platform = $state<boolean>(false);
let editorOpen = $state(false);
let presenceEditorOpen = $state(false);
function disableReload() {
if (window.location.hostname !== 'tauri.localhost') {
@@ -120,21 +147,71 @@
}
onMount(async () => {
if (editorWindow) return;
try {
const ver = await getLauncherVersion();
if (ver) launcherVersion.set(ver);
} catch {
// ignore
}
if (editorWindow || presenceEditorWindow) return;
// The editor is a separate WebView, so the launcher cannot observe its
// focus directly. Native code emits this state when the window is created
// or destroyed, and the launcher pauses its own theme media while it is
// open so both previews cannot play over each other.
const syncThemeEditorData = async () => {
try {
await emit('sync-theme-editor-global-data', {
user: get(currentUser),
info: get(currentUserInfo),
friends: get(onlineFriends),
osuBuild: get(osuBuild),
osuStream: get(osuStream),
serverPing: get(serverPing),
launcherVersion: get(launcherVersion),
});
} catch {}
};
try {
unlistenRequestEditorData = await listen('request-theme-editor-global-data', () => {
syncThemeEditorData();
});
} catch (error) {
console.warn('Could not listen for theme editor data requests', error);
}
try {
unlistenEditorState = await listen<boolean>('theme-editor-state', (event) => {
themeMediaSuspended.set(event.payload === true);
const isOpen = event.payload === true;
themeMediaSuspended.set(isOpen);
editorOpen = isOpen;
if (isOpen) syncThemeEditorData();
});
if (await WebviewWindow.getByLabel('theme-editor')) themeMediaSuspended.set(true);
if (await WebviewWindow.getByLabel('theme-editor')) {
themeMediaSuspended.set(true);
editorOpen = true;
syncThemeEditorData();
}
} catch (error) {
console.warn('Could not sync the theme editor media state', error);
}
try {
unlistenPresenceEditorState = await listen<boolean>('presence-editor-state', (event) => {
const isOpen = event.payload === true;
presenceEditorOpen = isOpen;
if (isOpen) syncThemeEditorData();
});
if (await WebviewWindow.getByLabel('presence-editor')) {
presenceEditorOpen = true;
syncThemeEditorData();
}
} catch (error) {
console.warn('Could not sync the presence editor state', error);
}
window.Buffer = Buffer;
disableReload();
@@ -320,7 +397,69 @@
{#if editorWindow}
<ThemeEditorWindow />
{:else if presenceEditorWindow}
<DiscordPresenceEditor standalone />
{:else}
{#if editorOpen}
<div
class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black/85 p-6 text-center select-none backdrop-blur-md"
>
<div
class="mb-4 flex size-14 items-center justify-center rounded-2xl bg-primary/20 text-primary ring-1 ring-inset ring-white/10"
>
<Sparkles class="size-7" />
</div>
<h2 class="text-base font-semibold text-white">Theme editor is open</h2>
<p class="mt-1.5 max-w-xs text-xs leading-relaxed text-muted-foreground">
The launcher is locked while editing themes. Finish your changes or close the editor to
resume.
</p>
<div class="mt-5 flex items-center gap-2.5">
<Button class="h-8 gap-1.5 px-3 text-xs" onclick={() => invoke('focus_theme_editor')}>
Bring Editor to Front
</Button>
<Button
variant="secondary"
class="h-8 px-3 text-xs"
onclick={() => invoke('close_theme_editor')}
>
Close Editor
</Button>
</div>
</div>
{:else if presenceEditorOpen}
<div
class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black/85 p-6 text-center select-none backdrop-blur-md"
>
<div
class="mb-4 flex size-14 items-center justify-center rounded-2xl bg-[#5865F2]/20 text-[#5865F2] ring-1 ring-inset ring-[#5865F2]/30"
>
<svg class="size-7 fill-current" viewBox="0 0 24 24">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994.021-.041.001-.09-.041-.106a13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.929 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.894.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div>
<h2 class="text-base font-semibold text-white">Discord Rich Presence editor is open</h2>
<p class="mt-1.5 max-w-xs text-xs leading-relaxed text-muted-foreground">
The launcher is locked while editing your Rich Presence embed. Finish your changes or close
the editor to resume.
</p>
<div class="mt-5 flex items-center gap-2.5">
<Button class="h-8 gap-1.5 px-3 text-xs" onclick={() => invoke('focus_presence_editor')}>
Bring Editor to Front
</Button>
<Button
variant="secondary"
class="h-8 px-3 text-xs"
onclick={() => invoke('close_presence_editor')}
>
Close Editor
</Button>
</div>
</div>
{/if}
{#if $customCursor}
<OsuCursor smoothCursor={$cursorSmoothening} />
{/if}
+91 -63
View File
@@ -4,6 +4,7 @@
import { ezppfarm } from '$lib/api/ezpp';
import DiagnosticsPanel from '$lib/components/diagnostics/DiagnosticsPanel.svelte';
import LogViewer from '$lib/components/logs/LogViewer.svelte';
import DiscordPresenceEditor from '$lib/components/presence/DiscordPresenceEditor.svelte';
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -20,6 +21,11 @@
import * as Select from '$lib/components/ui/select';
import { config } from '$lib/config';
import { currentUserInfo } from '$lib/data';
import {
loadDiscordPresenceConfig,
pushIdlePresenceFromConfig,
pushPlayingPresenceFromConfig,
} from '$lib/discordPresenceConfig';
import { useDropZone } from '$lib/dropZone.svelte';
import {
getGamemodeInt,
@@ -280,6 +286,7 @@
let restoreCandidate = $state<{ theme: Theme; backup: ThemeBackup } | undefined>(undefined);
let restoringTheme = $state(false);
let logViewerOpen = $state(false);
let presenceEditorOpen = $state(false);
const themeFilterOptions = [
{ id: 'all', label: 'All themes', icon: 'all-themes' },
@@ -940,17 +947,7 @@
const userStats = await ezppfarm.getUserInfo($currentUser.id, 'stats');
const userStatus = await ezppfarm.getUserStatus($currentUser.id);
if (userStatus?.player_status.online) {
let largeImageKey = 'ezppfarm';
let details = 'Idle...';
let customButton: { text?: string; url?: string } = {
text: undefined,
url: undefined,
};
let state =
userStatus.player_status.status.info_text.length > 0
? userStatus.player_status.status.info_text
: ' ';
let actionName = 'Idle...';
const gamemode = getModeAndTypeFromGamemode(userStatus.player_status.status.mode);
const gamemodeName = getGamemodeName(
modeIntToStr(gamemode.mode),
@@ -959,76 +956,75 @@
switch (userStatus.player_status.status.action) {
case EZPPActionStatus.AFK:
details = 'AFK...';
state = ' ';
actionName = 'AFK...';
break;
case EZPPActionStatus.PLAYING:
details = 'Playing...';
actionName = 'Playing...';
break;
case EZPPActionStatus.EDITING:
details = 'Editing...';
actionName = 'Editing...';
break;
case EZPPActionStatus.MODDING:
details = 'Modding...';
actionName = 'Modding...';
break;
case EZPPActionStatus.MULTIPLAYER_SELECT:
details = 'Multiplayer: Selecting a Beatmap...';
state = ' ';
actionName = 'Multiplayer: Selecting a Beatmap...';
break;
case EZPPActionStatus.WATCHING:
details = 'Watching...';
actionName = 'Watching...';
break;
case EZPPActionStatus.TESTING:
details = 'Testing...';
actionName = 'Testing...';
break;
case EZPPActionStatus.SUBMITTING:
details = 'Submitting...';
actionName = 'Submitting...';
break;
case EZPPActionStatus.MULTIPLAYER_IDLE:
details = 'Multiplayer: Idle...';
state = ' ';
actionName = 'Multiplayer: Idle...';
break;
case EZPPActionStatus.MULTIPLAYER_PLAYING:
details = 'Multiplayer: Playing...';
actionName = 'Multiplayer: Playing...';
break;
case EZPPActionStatus.DIRECT:
details = 'Browsing osu!direct...';
state = ' ';
actionName = 'Browsing osu!direct...';
break;
}
if (userStatus.player_status.status.beatmap !== null) {
const beatmapCoverImage = `https://assets.ppy.sh/beatmaps/${userStatus.player_status.status.beatmap.set_id}/covers/[email protected]`;
const isValidImage = await urlIsValidImage(beatmapCoverImage);
if (isValidImage) largeImageKey = beatmapCoverImage;
if (userStatus.player_status.status.action !== EZPPActionStatus.DIRECT)
customButton = {
text: 'View Beatmap',
url: `https://ez-pp.farm/beatmapsets/${userStatus.player_status.status.beatmap.set_id}/${userStatus.player_status.status.beatmap.id}`,
};
let beatmapCoverImage: string | null = null;
const beatmap = userStatus.player_status.status.beatmap;
let beatmapString =
userStatus.player_status.status.info_text.length > 0
? userStatus.player_status.status.info_text
: ' ';
if (beatmap !== null) {
beatmapString = `${beatmap.artist} - ${beatmap.title} [${beatmap.version}]`;
const coverCandidate = `https://assets.ppy.sh/beatmaps/${beatmap.set_id}/covers/[email protected]`;
const isValidImage = await urlIsValidImage(coverCandidate);
if (isValidImage) beatmapCoverImage = coverCandidate;
}
details = `[${gamemodeName}] ${details}`;
try {
const currentModeStats =
userStats?.player.stats[userStatus.player_status.status.mode];
let username = $currentUser.name;
if (currentModeStats && currentModeStats.rank > 0)
username += ` (#${currentModeStats.rank})`;
await Promise.all([
presence.updateUser({
username,
id: $currentUser.id.toFixed(),
}),
presence.updateButton(customButton),
presence.updateStatus({
details,
state,
largeImageKey,
}),
]);
await pushPlayingPresenceFromConfig({
user: $currentUser,
mode: gamemodeName,
action: actionName,
beatmap: beatmapString,
artist: beatmap?.artist,
title: beatmap?.title,
diff: beatmap?.version,
version: $launcherVersion || undefined,
beatmapCoverUrl: beatmapCoverImage,
beatmapUrl: beatmap
? `https://ez-pp.farm/beatmapsets/${beatmap.set_id}/${beatmap.id}`
: null,
rank: currentModeStats?.rank,
pp: currentModeStats?.pp,
accuracy: currentModeStats ? `${currentModeStats.acc.toFixed(2)}%` : undefined,
});
} catch (err) {
console.log(err);
}
@@ -1052,17 +1048,10 @@
if (presenceUpdater) {
window.clearInterval(presenceUpdater);
try {
await Promise.all([
presence.updateUser({
username: ' ',
id: null,
}),
presence.updateStatus({
details: ' ',
state: 'Idle in Launcher...',
largeImageKey: 'ezppfarm',
}),
]);
await pushIdlePresenceFromConfig({
user: $currentUser,
version: $launcherVersion || undefined,
});
} catch (err) {
console.log(err);
}
@@ -1356,6 +1345,18 @@
}
})();
loadDiscordPresenceConfig().then((cfg) => {
if ($discordPresence) {
pushIdlePresenceFromConfig(
{
user: $currentUser,
version: $launcherVersion || undefined,
},
cfg
).catch(() => {});
}
});
animate(ezppLogo, {
opacity: [0, 1],
scale: [0.95, 1],
@@ -2494,6 +2495,31 @@
</div>
</div>
<div class="flex items-center justify-between gap-6 py-3">
<div class="min-w-0">
<Label class="text-sm">Activity Embed Customization</Label>
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
Customize details, actions, templates, interactive buttons,
and preview live Discord cards.
</p>
</div>
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 px-3 text-xs shrink-0"
onclick={async () => {
try {
await invoke('open_presence_editor');
} catch {
presenceEditorOpen = true;
}
}}
>
<ThemeIcon name="palette" set={iconSet} class="size-3.5" />
Customize Activity
</Button>
</div>
<div class="flex items-start justify-between gap-8 py-3">
<div class="min-w-0">
<Label class="text-sm" for="setting-tracking"
@@ -3296,6 +3322,8 @@
{#if logViewerOpen}
<LogViewer onclose={() => (logViewerOpen = false)} />
{/if}
<DiscordPresenceEditor bind:open={presenceEditorOpen} />
</div>
<style>