Compare commits

...

2 Commits

8 changed files with 616 additions and 265 deletions
+69
View File
@@ -185,6 +185,75 @@ pub async fn get_beatmapsets_count(folder: String) -> Option<u64> {
return Some(count);
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SkinInfo {
pub name: String,
pub author: Option<String>,
pub modified: u64,
}
#[tauri::command]
pub async fn get_skins(folder: String) -> Option<Vec<SkinInfo>> {
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use tokio::fs;
let skins_folder = PathBuf::from(folder).join("Skins");
if !skins_folder.exists() {
return None;
}
let mut entries = fs::read_dir(skins_folder).await.ok()?;
let mut skins = Vec::new();
while let Some(entry) = entries.next_entry().await.ok()? {
if !entry.file_type().await.ok()?.is_dir() {
continue;
}
let dir_path = entry.path();
let skin_ini = dir_path.join("skin.ini");
if !skin_ini.exists() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let author = fs::read_to_string(&skin_ini)
.await
.ok()
.and_then(|content| {
content.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
key.trim()
.eq_ignore_ascii_case("Author")
.then(|| value.trim().to_string())
})
});
let modified = entry
.metadata()
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
skins.push(SkinInfo {
name,
author,
modified,
});
}
Some(skins)
}
#[tauri::command]
pub async fn get_skins_count(folder: String) -> Option<u64> {
let path = PathBuf::from(folder);
+3 -2
View File
@@ -8,8 +8,8 @@ use crate::commands::{
check_for_corruption, download_ezpp_launcher_update, download_ezpp_launcher_update_files,
encrypt_string, exit, find_osu_installation, 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, is_osu_running, open_url_in_browser,
get_osu_skin, get_osu_version, get_platform, get_skins, get_skins_count, has_net8,
has_osuwinello, has_wmctrl, install_ezpp_launcher_update, is_osu_running, open_url_in_browser,
presence_connect, presence_disconnect, presence_is_connected, presence_update_status,
presence_update_user, replace_ui_files, run_osu, run_osu_updater, set_osu_config_values,
set_osu_user_config_values, valid_osu_folder,
@@ -37,6 +37,7 @@ pub fn run() {
find_osu_installation,
valid_osu_folder,
get_beatmapsets_count,
get_skins,
get_skins_count,
get_osu_version,
get_osu_release_stream,
@@ -0,0 +1,143 @@
<script lang="ts">
// Props
let { children, class: className = '', topOffset = 0 } = $props();
let wrapper: HTMLDivElement;
let container: HTMLDivElement;
let thumb: HTMLDivElement;
// Thumb state
let thumbHeight = $state(20);
let thumbTop = $state(0);
// Drag state
let dragging = false;
let startY = 0;
let startScroll = 0;
/** Update thumb height & position */
function updateScrollbar() {
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const scrollable = scrollHeight - clientHeight;
if (scrollable <= 0) {
thumbHeight = 0;
thumbTop = 0;
return;
}
thumbHeight = Math.max((clientHeight / scrollHeight) * clientHeight, 24);
const trackHeight = clientHeight - thumbHeight - topOffset;
thumbTop = (scrollTop / scrollable) * trackHeight;
}
/** Start dragging the thumb */
function startDrag(e: PointerEvent) {
dragging = true;
startY = e.clientY;
startScroll = container.scrollTop;
thumb.setPointerCapture(e.pointerId);
}
/** Drag the thumb */
function drag(e: PointerEvent) {
if (!dragging) return;
const delta = e.clientY - startY;
const { scrollHeight, clientHeight } = container;
const scrollable = scrollHeight - clientHeight;
const trackHeight = clientHeight - thumbHeight - topOffset;
container.scrollTop = startScroll + (delta / trackHeight) * scrollable;
}
/** End dragging */
function endDrag(e: PointerEvent) {
dragging = false;
thumb.releasePointerCapture(e.pointerId);
}
// Reactive effect to update scrollbar
$effect(() => updateScrollbar());
// ResizeObserver for dynamic content
$effect(() => {
if (!container) return;
const observer = new ResizeObserver(updateScrollbar);
observer.observe(container);
return () => observer.disconnect();
});
</script>
<div class="wrapper" bind:this={wrapper}>
<!-- Scrollable content -->
<div class={`content ${className}`} bind:this={container} onscroll={updateScrollbar}>
{@render children?.()}
</div>
<!-- Custom scrollbar -->
<div class="scrollbar" style="top: {topOffset}px;">
<div
class="thumb"
bind:this={thumb}
onpointerdown={startDrag}
onpointermove={drag}
onpointerup={endDrag}
onpointercancel={endDrag}
style="height:{thumbHeight}px; transform:translateY({thumbTop}px);"
></div>
</div>
</div>
<style>
.wrapper {
position: relative;
height: 100%;
width: 100%;
}
/* Scrollable content */
.content {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none; /* Firefox */
}
.content::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
/* Scrollbar track */
.scrollbar {
position: absolute;
right: 2px;
bottom: 0;
width: 12px;
background: var(--color-theme-950);
border-radius: 999px;
}
/* Thumb */
.thumb {
position: absolute;
width: 100%;
background: var(--color-theme-800);
border-radius: 999px;
cursor: grab;
transition: background 0.15s;
}
.thumb:active {
cursor: grabbing;
background: var(--color-theme-600);
}
.thumb:hover {
background: var(--color-theme-700);
}
</style>
+2 -1
View File
@@ -32,7 +32,8 @@ export const serverConnectionFails = writable(0);
export const onlineFriends = writable<number | undefined>(undefined);
export const beatmapSets = writable<number | undefined>(undefined);
export const skins = writable<number | undefined>(undefined);
export const skinsCount = writable<number | undefined>(undefined);
export const skins = writable<{ name: string; author: string | undefined; modified: number }[]>([]);
export const osuStream = writable<string | undefined>(undefined);
export const osuBuild = writable<string | undefined>(undefined);
+17 -7
View File
@@ -5,6 +5,18 @@ import { betterFetch } from '@better-fetch/fetch';
const updateUrl = 'https://ez-pp.farm/api/ezpplauncher';
export function normalizeOsuName(name: string): string {
return (
name
.trim()
// Remove leading symbols/spaces
.replace(/^[^a-zA-Z0-9]+/, '')
// Normalize spacing
.replace(/\s+/g, ' ')
.toLowerCase()
);
}
export const getHWID = async () => {
const hwid = await invoke('get_hwid');
return typeof hwid === 'string' ? hwid : undefined;
@@ -55,13 +67,6 @@ export const getBeatmapSetsCount = async (folder: string) => {
return typeof result === 'number' ? result : 0;
};
export const getSkinsCount = async (folder: string) => {
const result = await invoke('get_skins_count', {
folder,
});
return typeof result === 'number' ? result : 0;
};
export const getSkin = async (folder: string) => {
const result = await invoke('get_osu_skin', {
folder,
@@ -151,3 +156,8 @@ export const downloadUpdate = async (
}
};
export const installUpdate = async () => await invoke('install_ezpp_launcher_update');
export const getSkins = async (folder: string) =>
await invoke<{ name: string; author: string | undefined; modified: number }[] | undefined>(
'get_skins',
{ folder }
);
+149 -34
View File
@@ -21,6 +21,7 @@
serverConnectionFails,
serverPing,
skins,
skinsCount,
trackingEnabled,
} from '@/global';
import {
@@ -38,6 +39,8 @@
ArrowRight,
Settings,
House,
Paintbrush,
FileQuestionMark,
} from 'lucide-svelte';
import NumberFlow from '@number-flow/svelte';
import * as AlertDialog from '@/components/ui/alert-dialog';
@@ -50,7 +53,7 @@
releaseStreamToReadable,
urlIsValidImage,
} from '@/utils';
import { fade, fly, scale } from 'svelte/transition';
import { fade, fly } from 'svelte/transition';
import { Checkbox } from '@/components/ui/checkbox';
import Label from '@/components/ui/label/label.svelte';
import {
@@ -85,7 +88,7 @@
getEZPPLauncherUpdateFiles,
getReleaseStream,
getSkin,
getSkinsCount,
getSkins,
getVersion,
hasNet8,
hasOsuWinello,
@@ -108,8 +111,9 @@
import * as DropdownMenu from '@/components/ui/dropdown-menu';
import DownloadButton from '@/components/ui/download-button/DownloadButton.svelte';
import { animate } from 'animejs';
import AnimatedBg from '@/components/ui/animated-bg/AnimatedBg.svelte';
import { sileo } from 'sileo';
import Check from '@lucide/svelte/icons/check';
import ScrollContainer from '@/components/ui/scroll-container/ScrollContainer.svelte';
let selectedView = $state('home');
let progress = $state(-1);
@@ -173,8 +177,11 @@
const beatmapSetCount: number | null = await getBeatmapSetsCount(selectedPath);
if (beatmapSetCount) beatmapSets.set(beatmapSetCount);
const skinsCount: number | null = await getSkinsCount(selectedPath);
if (skinsCount !== null) skins.set(skinsCount);
const skins_list = await getSkins(selectedPath);
if (skins_list) {
skins.set(skins_list);
skinsCount.set(skins_list.length);
}
const skin: string = await getSkin(selectedPath);
currentSkin.set(skin);
@@ -188,6 +195,8 @@
const launch = async () => {
if ($launching) return;
launchInfo = 'Checking if osu! is already running...';
launching.set(true);
if ($trackingEnabled) umami.track('app_launch_osu');
const osuRunning = await isOsuRunning();
if (osuRunning) {
@@ -200,8 +209,10 @@
description: 'text-center!',
},
});
launching.set(false);
return;
}
launchInfo = 'Checking osu! installation...';
if (!$osuBuild) {
sileo.error({
title: 'Hmmm...',
@@ -211,12 +222,12 @@
description: 'text-center!',
},
});
launching.set(false);
return;
}
const osuPath = $osuInstallationPath;
launchInfo = 'Validating osu! installation...';
launching.set(true);
const validFolder = await isValidOsuFolder(osuPath);
if (!validFolder) {
@@ -603,8 +614,11 @@
const beatmapSetCount = await getBeatmapSetsCount(osuPath);
if (beatmapSetCount) beatmapSets.set(beatmapSetCount);
const skinCount = await getSkinsCount(osuPath);
if (skinCount !== null) skins.set(skinCount);
const skinsList = await getSkins(osuPath);
if (skinsList) {
skins.set(skinsList);
skinsCount.set(skinsList.length);
}
const skin = await getSkin(osuPath);
currentSkin.set(skin);
@@ -914,14 +928,15 @@
<div
class="p-3 border-r border-r-theme-900 flex flex-col items-center gap-2 z-10 bg-black/40 backdrop-blur-sm"
>
<div class="bg-primary-800/80 border border-primary-700/50 rounded-[1.1rem] p-1">
<div class="bg-primary-800/80 ring-1 ring-inset ring-white/15 rounded-[1.1rem] p-1.5">
<img class="pointer-events-none" src={Logo} alt="logo" bind:this={ezppLogo} />
</div>
<Badge class="text-[0.5rem] py-0 px-2">{$launcherVersion || 'dev'}</Badge>
<Button
class="flex size-12 items-center gap-2 border {selectedView === 'home'
? 'bg-primary-300/50 border-primary-300/50'
: 'bg-black/20 border-black/20'} hover:bg-primary-300/50 hover:border-primary-300/50 rounded-[0.85rem] p-3 mt-3"
class="flex size-12 items-center gap-2 ring-1 ring-inset ring-white/15 {selectedView ===
'home'
? 'bg-primary-300/50'
: 'bg-black/20 border-black/20'} hover:bg-primary-300/50 rounded-[0.85rem] p-3 mt-3"
disabled={$launching}
onclick={() => {
if (!$launching) selectedView = 'home';
@@ -930,9 +945,22 @@
<House class="text-theme-200 !size-5" />
</Button>
<Button
class="flex size-12 items-center gap-2 border {selectedView === 'settings'
? 'bg-primary-300/50 border-primary-300/50'
: 'bg-black/20 border-black/20'} hover:bg-primary-300/50 hover:border-primary-300/50 rounded-[0.85rem] p-3 mt-3"
class="flex size-12 items-center gap-2 ring-1 ring-inset ring-white/15 {selectedView ===
'skins'
? 'bg-primary-300/50'
: 'bg-black/20 border-black/20'} hover:bg-primary-300/50 rounded-[0.85rem] p-3 mt-3"
disabled={$launching}
onclick={() => {
if (!$launching) selectedView = 'skins';
}}
>
<Paintbrush class="text-theme-200 !size-5" />
</Button>
<Button
class="flex size-12 items-center gap-2 ring-1 ring-inset ring-white/15 {selectedView ===
'settings'
? 'bg-primary-300/50'
: 'bg-black/20 border-black/20'} hover:bg-primary-300/50 rounded-[0.85rem] p-3 mt-3"
disabled={$launching}
onclick={() => {
if (!$launching) selectedView = 'settings';
@@ -1012,10 +1040,10 @@
<div class="flex items-center gap-2">
<Brush class="size-3.5 text-amber-400" />
<span class="text-[11px] text-white/70 font-medium">
{#if !$skins && $skins !== 0}
{#if !$skinsCount && $skinsCount !== 0}
<LoaderCircle class="animate-spin" size={12} />
{:else}
{numberHumanReadable($skins ?? 0)}
{numberHumanReadable($skinsCount ?? 0)}
{/if}
</span>
</div>
@@ -1114,7 +1142,7 @@
{#if $currentUserInfo}
<div
class="m-1 w-72 h-48 bg-black/40 backdrop-blur-sm rounded-md border border-black/40 flex flex-col items-center p-3"
class="m-1 w-72 h-48 bg-black/40 backdrop-blur-sm rounded-md ring-1 ring-inset ring-white/5 flex flex-col items-center p-3"
in:fly={{ duration: $reduceAnimations ? 0 : 400, y: 5, opacity: 0 }}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
@@ -1267,7 +1295,7 @@
</div>
{:else if selectedView === 'settings'}
<div
class="h-[100vh] w-full flex flex-col items-center justify-center"
class="h-[100vh] w-full flex flex-col items-center justify-center bg-black/20 backdrop-blur-sm"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
@@ -1276,9 +1304,7 @@
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
<div class="p-8 w-full">
<div class="bg-black/40 backdrop-blur-sm py-8 px-6 rounded-lg">
<div class="grid grid-cols-[1fr_auto] gap-y-5 items-center px-6">
<div class="grid grid-cols-[1fr_auto] w-full gap-y-5 items-center px-6">
<div class="flex flex-col">
<Label class="text-sm" for="setting-patch">Patching</Label>
<div class="text-muted-foreground text-xs">
@@ -1341,8 +1367,7 @@
<div class="flex flex-col">
<Label class="text-sm" for="setting-reduce-animations">Reduce Animations</Label>
<div class="text-muted-foreground text-xs">
Disables some animations in the Launcher to improve performance on low-end
devices.
Disables some animations in the Launcher to improve performance on low-end devices.
</div>
</div>
<Checkbox
@@ -1393,7 +1418,7 @@
></Checkbox>
</div>
<div
class="grid grid-cols-[0.7fr_auto] gap-y-1 items-center border-theme-800 pl-6 pr-5 pb-4"
class="grid grid-cols-[0.7fr_auto] gap-y-1 mt-3 w-full items-center border-theme-800 pl-6 pr-5 pb-4"
>
<div class="flex flex-col">
<Label class="text-sm" for="setting-custom-cursor">osu! installation path</Label>
@@ -1416,9 +1441,7 @@
<div class="flex flex-col">
<Label class="text-sm" for="setting-custom-cursor">patcher release stream</Label>
<div class="text-muted-foreground text-xs">
test different versions of the patcher
</div>
<div class="text-muted-foreground text-xs">test different versions of the patcher</div>
</div>
<div class="flex flex-row w-full">
<Select.Root
@@ -1470,11 +1493,9 @@
</div>
</div>
</div>
</div>
</div>
{:else if selectedView === 'login'}
<div
class="h-[100vh] w-full flex flex-col items-center justify-center"
class="h-[100vh] w-full flex flex-col items-center justify-center bg-black/20 backdrop-blur-sm"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
@@ -1483,9 +1504,8 @@
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
<div class="p-8 px-52 w-full">
<form onsubmit={performLogin} class="bg-black/40 backdrop-blur-sm rounded-lg p-8">
<div class="flex flex-col items-center justify-center mb-4">
<form onsubmit={performLogin} class="w-96">
<div class="flex flex-col items-center justify-center mb-8">
<span class="text-xl font-semibold">Login to EZPPFarm</span>
<span class="text-xs text-muted-foreground"
>Use your EZPPFarm account to login to EZPPLauncher</span
@@ -1524,7 +1544,102 @@
</Button>
</form>
</div>
{:else if selectedView === 'skins'}
<div
class="h-[100vh] w-full flex flex-col items-center justify-center bg-black/20 backdrop-blur-sm"
in:fly={{
duration: $reduceAnimations ? 0 : 400,
delay: $reduceAnimations ? 0 : 400,
y: 5,
opacity: 0,
}}
out:fly={{ duration: $reduceAnimations ? 0 : 400, y: -5, opacity: 0 }}
>
{#if $skins.length > 0}
{@const skinsSorted = $skins.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
)}
<ScrollContainer
class="flex flex-col max-h-[100vh] overflow-y-auto items-center gap-4 p-16 w-full"
topOffset={35}
>
{#each skinsSorted as skin (skin.name)}
<div
class={`group w-full flex items-center border rounded-lg transition-all duration-150 ${
skin.name === $currentSkin
? 'bg-primary/20 border-primary/40'
: 'bg-black/40 border-black/60 hover:bg-black/70 hover:border-border'
}`}
>
<div class="flex-1 min-w-0 px-5 py-3.5">
<p class="text-sm text-foreground/90 truncate font-medium line-clamp-1">
{skin.name}
</p>
<div class="flex items-center gap-4 mt-1">
<span class="text-[11px] text-muted-foreground">
{skin.author || 'Unknown'}
</span>
<span class="text-[11px] text-muted-foreground/50">
{new Date(skin.modified * 1000).toLocaleDateString(undefined, {
year: '2-digit',
month: 'short',
day: 'numeric',
})}
</span>
</div>
</div>
<div
class="flex items-center gap-1.5 pr-3 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
{#if $currentSkin !== skin.name}
<button
transition:fade={{
duration: 150,
}}
class="p-2 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
onclick={async () => {
if (skin.name === $currentSkin) return;
await setUserConfigValues($osuInstallationPath, [
{ key: 'Skin', value: skin.name },
]);
currentSkin.set(skin.name);
sileo.success({
title: 'Skin applied!',
description: `${skin.name} is now your active skin!`,
fill: '#181825',
styles: {
description: 'text-center!',
},
});
}}
>
<Check class="w-4 h-4" />
</button>
{/if}
<!-- <button
class="p-2 rounded-md transition-colors text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 class="w-4 h-4" />
</button> -->
</div>
</div>
{/each}
</ScrollContainer>
{:else}
<div class="flex flex-col items-center justify-center gap-4">
<FileQuestionMark class="size-12 text-muted-foreground" strokeWidth={1.5} />
<span class="text-sm text-muted-foreground">No skins found!</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style lang="scss">
.custom-scrollbox {
scrollbar-color: #999 transparent;
scrollbar-width: 0px !important;
}
</style>
+11 -3
View File
@@ -14,6 +14,7 @@
osuBuild,
osuStream,
skins,
skinsCount,
} from '@/global';
import {
cursorSmoothness,
@@ -34,7 +35,7 @@
getEZPPLauncherStreams,
getReleaseStream,
getSkin,
getSkinsCount,
getSkins,
getVersion,
isValidOsuFolder,
} from '@/osuUtil';
@@ -151,8 +152,15 @@
if (beatmapSetCount !== null) beatmapSets.set(beatmapSetCount);
currentLoadingInfo.set('Counting skins...');
const skinCount = await getSkinsCount($osuInstallationPath);
if (skinCount !== null) skins.set(skinCount);
const skins_list = await getSkins($osuInstallationPath);
console.log(skins_list);
if (skins_list) {
skins.set(skins_list);
skinsCount.set(skins_list.length);
} else {
skinsCount.set(0);
}
const skin: string = await getSkin($osuInstallationPath);
currentSkin.set(skin);
}
+8 -4
View File
@@ -27,6 +27,7 @@
platform,
presenceLoading,
skins,
skinsCount,
} from '@/global';
import Launch from './Launch.svelte';
import Confetti from 'svelte-confetti';
@@ -35,7 +36,7 @@
getBeatmapSetsCount,
getReleaseStream,
getSkin,
getSkinsCount,
getSkins,
getVersion,
isValidOsuFolder,
} from '@/osuUtil';
@@ -102,9 +103,12 @@
beatmapSets.set(beatmapSetCount);
}
const skinsCount: number | null = await getSkinsCount(osuInstallPath);
if (skinsCount) {
skins.set(skinsCount);
const skins_list = await getSkins(osuInstallPath);
if (skins_list) {
skins.set(skins_list);
skinsCount.set(skins_list.length);
} else {
skinsCount.set(0);
}
const skin: string = await getSkin(osuInstallPath);