feat: add discord rich presence editor window and interactive embed preview
This commit is contained in:
1 parent
e3abea8d83
commit
386c829455
30 files changed
+4102
-263
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user