feat(skins): integrate full offline skin changer with 3D preview, model toggle, file import, and online URL lookup
This commit is contained in:
parent
6a20bcfec5
commit
a363af6d20
9 changed files with 798 additions and 95 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -4498,8 +4498,11 @@ dependencies = [
|
|||
"open",
|
||||
"polyio",
|
||||
"regex",
|
||||
"reqwest 0.13.3",
|
||||
"rfd",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ notify.workspace = true
|
|||
|
||||
anyhow.workspace = true
|
||||
regex.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
cargo-packager-updater = "=0.2.3"
|
||||
|
||||
|
|
|
|||
|
|
@ -100,9 +100,9 @@ fn navbar_center() -> impl IntoElement {
|
|||
nav_label: "Versions",
|
||||
})
|
||||
.child(NavLink {
|
||||
active: false,
|
||||
target: NavTarget::External("https://store.polyfrost.org"),
|
||||
nav_label: "Cosmetics",
|
||||
active: route == Route::AccountSkins {},
|
||||
target: NavTarget::Route(Route::AccountSkins {}),
|
||||
nav_label: "Skins",
|
||||
})
|
||||
.child(NavLink {
|
||||
active: matches!(
|
||||
|
|
@ -142,6 +142,7 @@ fn browse_target() -> Route {
|
|||
}
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
#[allow(dead_code)]
|
||||
enum NavTarget {
|
||||
Route(Route),
|
||||
External(&'static str),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ pub use queries::{
|
|||
use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles,
|
||||
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
|
||||
use_package_search, use_package_updates, use_package_versions, use_package_versions_when,
|
||||
use_player_profile, use_player_skin, use_provider_versions, use_refresh_account,
|
||||
use_player_profile, use_player_skin, use_custom_skin, delete_account_skin,
|
||||
fetch_skin_online, save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account,
|
||||
use_refresh_all_accounts, use_remove_account, use_screenshot_action,
|
||||
use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report,
|
||||
use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,10 @@ pub use settings_profiles::{
|
|||
try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile,
|
||||
use_named_profiles,
|
||||
};
|
||||
pub use skin::use_player_skin;
|
||||
pub use skin::{
|
||||
delete_account_skin, fetch_skin_online, invalidate_skin_queries, save_account_skin,
|
||||
use_custom_skin, use_player_skin,
|
||||
};
|
||||
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
|
||||
pub use storage::{
|
||||
StorageAction, StorageActionMutation, StorageReportQuery, UseStorageAction,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,92 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use freya::prelude::*;
|
||||
use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query};
|
||||
use oneclient_core::LauncherError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppAssets;
|
||||
|
||||
const SKIN_STALE: Duration = Duration::from_secs(60);
|
||||
const SKIN_CLEAN: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CustomSkinQuery;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CustomSkinKeys {
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct SkinMetadata {
|
||||
pub is_slim: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct CustomSkinData {
|
||||
pub bytes: Option<Bytes>,
|
||||
pub is_slim: bool,
|
||||
pub has_custom: bool,
|
||||
}
|
||||
|
||||
impl QueryCapability for CustomSkinQuery {
|
||||
type Ok = CustomSkinData;
|
||||
type Err = LauncherError;
|
||||
type Keys = CustomSkinKeys;
|
||||
|
||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let Ok(path) = oneclient_common::paths::skin_file_path(&keys.uuid) else {
|
||||
return Ok(CustomSkinData::default());
|
||||
};
|
||||
|
||||
if let Ok(bytes) = polyio::read(&path).await {
|
||||
if !bytes.is_empty() {
|
||||
let mut is_slim = false;
|
||||
if let Ok(meta_path) = oneclient_common::paths::skin_meta_path(&keys.uuid) {
|
||||
if let Ok(meta_bytes) = polyio::read(&meta_path).await {
|
||||
if let Ok(meta) = serde_json::from_slice::<SkinMetadata>(&meta_bytes) {
|
||||
is_slim = meta.is_slim;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(CustomSkinData {
|
||||
bytes: Some(Bytes::from(bytes)),
|
||||
is_slim,
|
||||
has_custom: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CustomSkinData::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_custom_skin(uuid: String) -> UseQuery<CustomSkinQuery> {
|
||||
use_query(
|
||||
Query::new(CustomSkinKeys { uuid }, CustomSkinQuery)
|
||||
.stale_time(SKIN_STALE)
|
||||
.clean_time(SKIN_CLEAN),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn invalidate_skin_queries(uuid: Option<&str>) {
|
||||
if let Some(uuid) = uuid {
|
||||
QueriesStorage::<CustomSkinQuery>::invalidate_matching(CustomSkinKeys {
|
||||
uuid: uuid.to_string(),
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
QueriesStorage::<CustomSkinQuery>::invalidate_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
||||
let custom_query = use_custom_skin(uuid.clone());
|
||||
let custom_data = crate::hooks::settled_or_loading(&custom_query);
|
||||
|
||||
let profile = super::use_player_profile(uuid.clone(), None::<String>);
|
||||
|
||||
let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile)
|
||||
|
|
@ -16,6 +99,14 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
|||
|
||||
let default_slim = (java_string_hash(&uuid) & 1) == 1;
|
||||
|
||||
if let Some(custom) = custom_data {
|
||||
if custom.has_custom {
|
||||
if let Some(bytes) = custom.bytes {
|
||||
return (bytes, custom.is_slim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
|
||||
Some((_, bytes)) => (bytes, is_slim),
|
||||
None if default_slim => (alex.read().clone(), true),
|
||||
|
|
@ -23,10 +114,135 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
fn java_string_hash(s: &str) -> i32 {
|
||||
pub fn java_string_hash(s: &str) -> i32 {
|
||||
let mut h: i32 = 0;
|
||||
for c in s.encode_utf16() {
|
||||
h = h.wrapping_mul(31).wrapping_add(c as i32);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
pub async fn save_account_skin(
|
||||
uuid: &str,
|
||||
username: Option<&str>,
|
||||
skin_bytes: &[u8],
|
||||
is_slim: bool,
|
||||
) -> Result<(), String> {
|
||||
let img = image::load_from_memory(skin_bytes)
|
||||
.map_err(|e| format!("Invalid skin image format: {e}"))?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
|
||||
let processed_bytes = if (w == 64 && h == 64) || (w == 128 && h == 128) {
|
||||
skin_bytes.to_vec()
|
||||
} else if w == 64 && h == 32 {
|
||||
let rgba = img.to_rgba8();
|
||||
let mut modern = image::RgbaImage::new(64, 64);
|
||||
image::imageops::overlay(&mut modern, &rgba, 0, 0);
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut out);
|
||||
modern
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
out
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Invalid dimensions ({w}x{h}). Minecraft skins must be 64x64 or 64x32 PNG."
|
||||
));
|
||||
};
|
||||
|
||||
let skins_dir = oneclient_common::paths::skins_dir().map_err(|e| e.to_string())?;
|
||||
polyio::create_dir_all(&skins_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let skin_path = oneclient_common::paths::skin_file_path(uuid).map_err(|e| e.to_string())?;
|
||||
polyio::write(&skin_path, &processed_bytes)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let meta_path = oneclient_common::paths::skin_meta_path(uuid).map_err(|e| e.to_string())?;
|
||||
let meta = SkinMetadata { is_slim };
|
||||
let meta_json = serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?;
|
||||
polyio::write(&meta_path, &meta_json)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
invalidate_skin_queries(Some(uuid)).await;
|
||||
|
||||
// Sync to CustomSkinLoader directory if username is provided
|
||||
if let Some(uname) = username {
|
||||
if let Ok(dot_mc) = oneclient_common::paths::shared_minecraft_dir() {
|
||||
let csl_dir = dot_mc.join("CustomSkinLoader").join("skins");
|
||||
if polyio::create_dir_all(&csl_dir).await.is_ok() {
|
||||
let _ = polyio::write(csl_dir.join(format!("{uname}.png")), &processed_bytes).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_account_skin(uuid: &str, username: Option<&str>) -> Result<(), String> {
|
||||
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(uuid) {
|
||||
let _ = polyio::remove_file(skin_path).await;
|
||||
}
|
||||
if let Ok(meta_path) = oneclient_common::paths::skin_meta_path(uuid) {
|
||||
let _ = polyio::remove_file(meta_path).await;
|
||||
}
|
||||
if let Some(uname) = username {
|
||||
if let Ok(dot_mc) = oneclient_common::paths::shared_minecraft_dir() {
|
||||
let csl_skin = dot_mc
|
||||
.join("CustomSkinLoader")
|
||||
.join("skins")
|
||||
.join(format!("{uname}.png"));
|
||||
let _ = polyio::remove_file(csl_skin).await;
|
||||
}
|
||||
}
|
||||
invalidate_skin_queries(Some(uuid)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_skin_online(query: &str) -> Result<(Vec<u8>, bool), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let trimmed = query.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Please enter a username or skin URL".to_string());
|
||||
}
|
||||
|
||||
let url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://minotar.net/skin/{trimmed}")
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download skin: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if !trimmed.starts_with("http") {
|
||||
let mineskin_url = format!("https://mineskin.eu/skin/{trimmed}");
|
||||
if let Ok(m_resp) = client.get(&mineskin_url).send().await {
|
||||
if m_resp.status().is_success() {
|
||||
if let Ok(bytes) = m_resp.bytes().await {
|
||||
let is_slim = (java_string_hash(trimmed) & 1) == 1;
|
||||
return Ok((bytes.to_vec(), is_slim));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(format!("Could not find skin for \"{trimmed}\""));
|
||||
}
|
||||
|
||||
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||
let _ = image::load_from_memory(&bytes)
|
||||
.map_err(|e| format!("Downloaded file is not a valid image: {e}"))?;
|
||||
let is_slim = (java_string_hash(trimmed) & 1) == 1;
|
||||
Ok((bytes.to_vec(), is_slim))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,6 +293,15 @@ fn hero(
|
|||
.on_press(on_open_offline)
|
||||
.child(Icon::new(IconType::Plus).size(16.))
|
||||
.text("Add offline"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.on_press(|_| {
|
||||
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
|
||||
})
|
||||
.child(Icon::new(IconType::Colors).size(16.))
|
||||
.text("Change Skin"),
|
||||
),
|
||||
)
|
||||
.map(error, |el, msg| {
|
||||
|
|
@ -628,6 +637,20 @@ impl Component for AccountRow {
|
|||
)
|
||||
.into_element()
|
||||
}))
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
let _ = freya::router::RouterContext::get().push(crate::Route::AccountSkins {});
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconType::Colors)
|
||||
.size(18.)
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
)
|
||||
.child({
|
||||
let is_offline = !is_microsoft;
|
||||
let mut edit_acc_state = self.edit_account;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,34 @@
|
|||
use freya::prelude::*;
|
||||
use oneclient_auth::MinecraftAccount;
|
||||
|
||||
use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel};
|
||||
use crate::hooks::{try_default_account, use_current_account};
|
||||
use crate::components::{Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput};
|
||||
use crate::hooks::{
|
||||
delete_account_skin, fetch_skin_online, save_account_skin, try_accounts, try_default_account,
|
||||
use_accounts, use_current_account, use_custom_skin,
|
||||
};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"];
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct AccountSkins;
|
||||
|
||||
impl Component for AccountSkins {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let account_uuid = try_default_account(&use_current_account()).map(|a| a.id.to_string());
|
||||
let current_account = use_current_account();
|
||||
let default_account = try_default_account(¤t_account);
|
||||
let accounts = try_accounts(&use_accounts()).unwrap_or_default();
|
||||
|
||||
let selected_id = use_state(|| default_account.as_ref().map(|a| a.id));
|
||||
|
||||
// Keep selected account in sync if none is explicitly picked
|
||||
let active_account = selected_id
|
||||
.read()
|
||||
.and_then(|id| accounts.iter().find(|a| a.id == id).cloned())
|
||||
.or_else(|| default_account.clone());
|
||||
|
||||
let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error)
|
||||
let show_url_modal = use_state(|| false);
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -20,16 +36,90 @@ impl Component for AccountSkins {
|
|||
.height(Size::fill())
|
||||
.overflow(Overflow::Clip)
|
||||
.padding(40.)
|
||||
.spacing(24.)
|
||||
.child(preview_panel(account_uuid))
|
||||
.child(side_panel())
|
||||
.spacing(28.)
|
||||
.child(preview_panel(active_account.clone(), status_msg))
|
||||
.child(side_panel(
|
||||
active_account,
|
||||
accounts,
|
||||
selected_id,
|
||||
status_msg,
|
||||
show_url_modal,
|
||||
is_loading,
|
||||
))
|
||||
.maybe_child((*show_url_modal.read()).then(|| {
|
||||
url_modal(selected_id, status_msg, show_url_modal, is_loading)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_panel(account_uuid: Option<String>) -> impl IntoElement {
|
||||
fn preview_panel(
|
||||
account: Option<MinecraftAccount>,
|
||||
status_msg: State<Option<(String, bool)>>,
|
||||
) -> impl IntoElement {
|
||||
let account_uuid = account.as_ref().map(|a| a.id.to_string());
|
||||
let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default());
|
||||
let custom_data = crate::hooks::settled_or_loading(&custom_query);
|
||||
let is_slim = custom_data.as_ref().map_or(false, |d| d.is_slim);
|
||||
let has_custom = custom_data.as_ref().map_or(false, |d| d.has_custom);
|
||||
|
||||
let acc_id_copy = account.as_ref().map(|a| a.id);
|
||||
let username_copy = account.as_ref().map(|a| a.username.clone());
|
||||
|
||||
let set_classic = {
|
||||
let acc_id = acc_id_copy;
|
||||
let uname = username_copy.clone();
|
||||
let mut status = status_msg;
|
||||
move |_| {
|
||||
let Some(id) = acc_id else { return };
|
||||
let uname = uname.clone();
|
||||
let uuid_str = id.to_string();
|
||||
spawn(async move {
|
||||
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||
if let Ok(bytes) = polyio::read(&skin_path).await {
|
||||
let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await;
|
||||
status.set(Some((
|
||||
"Model set to Classic (4px arms)".to_string(),
|
||||
false,
|
||||
)));
|
||||
} else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") {
|
||||
let _ = save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false)
|
||||
.await;
|
||||
status.set(Some(("Model set to Classic".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let set_slim = {
|
||||
let acc_id = acc_id_copy;
|
||||
let uname = username_copy.clone();
|
||||
let mut status = status_msg;
|
||||
move |_| {
|
||||
let Some(id) = acc_id else { return };
|
||||
let uname = uname.clone();
|
||||
let uuid_str = id.to_string();
|
||||
spawn(async move {
|
||||
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||
if let Ok(bytes) = polyio::read(&skin_path).await {
|
||||
let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await;
|
||||
status.set(Some((
|
||||
"Model set to Slim (3px arms)".to_string(),
|
||||
false,
|
||||
)));
|
||||
} else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") {
|
||||
let _ = save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true)
|
||||
.await;
|
||||
status.set(Some(("Model set to Slim".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(320.))
|
||||
.width(Size::px(340.))
|
||||
.height(Size::fill())
|
||||
.spacing(16.)
|
||||
.child(
|
||||
|
|
@ -57,74 +147,456 @@ fn preview_panel(account_uuid: Option<String>) -> impl IntoElement {
|
|||
.width(Size::fill())
|
||||
.main_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.children(
|
||||
ANIMATIONS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| anim_chip(name, i == 0).into_element()),
|
||||
.child(
|
||||
rect()
|
||||
.center()
|
||||
.padding(Gaps::new_symmetric(6., 14.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(if !is_slim {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_bg()
|
||||
})
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_press(set_classic)
|
||||
.child(
|
||||
label()
|
||||
.text("Classic (4px)")
|
||||
.font_size(12.)
|
||||
.font_weight(if !is_slim {
|
||||
FontWeight::SEMI_BOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.center()
|
||||
.padding(Gaps::new_symmetric(6., 14.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(if is_slim {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_bg()
|
||||
})
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_press(set_slim)
|
||||
.child(
|
||||
label()
|
||||
.text("Slim (3px)")
|
||||
.font_size(12.)
|
||||
.font_weight(if is_slim {
|
||||
FontWeight::SEMI_BOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.maybe_child(has_custom.then(|| {
|
||||
rect()
|
||||
.center()
|
||||
.child(
|
||||
label()
|
||||
.text("Custom Skin Active")
|
||||
.font_size(11.)
|
||||
.color(colors::success()),
|
||||
)
|
||||
.into_element()
|
||||
}))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn anim_chip(name: &'static str, active: bool) -> impl IntoElement {
|
||||
rect()
|
||||
.center()
|
||||
.padding(Gaps::new_symmetric(6., 12.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(if active {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_bg()
|
||||
})
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.child(
|
||||
label()
|
||||
.text(name)
|
||||
.font_size(12.)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
fn side_panel(
|
||||
active_account: Option<MinecraftAccount>,
|
||||
accounts: Vec<MinecraftAccount>,
|
||||
selected_id: State<Option<uuid::Uuid>>,
|
||||
status_msg: State<Option<(String, bool)>>,
|
||||
show_url_modal: State<bool>,
|
||||
is_loading: State<bool>,
|
||||
) -> impl IntoElement {
|
||||
let username = active_account
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
.unwrap_or_else(|| "No Account Selected".to_string());
|
||||
let account_id = active_account.as_ref().map(|a| a.id);
|
||||
let username_opt = active_account.as_ref().map(|a| a.username.clone());
|
||||
|
||||
// Import from local file
|
||||
let id_for_file = account_id;
|
||||
let uname_for_file = username_opt.clone();
|
||||
let mut status_file = status_msg;
|
||||
let mut loading_file = is_loading;
|
||||
|
||||
let import_from_file = move |_| {
|
||||
let Some(id) = id_for_file else { return };
|
||||
let uname = uname_for_file.clone();
|
||||
let uuid_str = id.to_string();
|
||||
|
||||
loading_file.set(true);
|
||||
spawn(async move {
|
||||
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||
.set_title("Select Minecraft Skin PNG")
|
||||
.add_filter("Minecraft Skin (*.png)", &["png"])
|
||||
.pick_file()
|
||||
.await
|
||||
{
|
||||
let bytes = handle.read().await;
|
||||
match save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await {
|
||||
Ok(()) => {
|
||||
status_file.set(Some((
|
||||
"Custom skin imported successfully!".to_string(),
|
||||
false,
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
status_file.set(Some((err, true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
loading_file.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Download/Export skin
|
||||
let id_for_dl = account_id;
|
||||
let uname_for_dl = username_opt.clone();
|
||||
let mut status_dl = status_msg;
|
||||
|
||||
let download_skin = move |_| {
|
||||
let Some(id) = id_for_dl else { return };
|
||||
let uname = uname_for_dl.clone().unwrap_or_else(|| "skin".to_string());
|
||||
let uuid_str = id.to_string();
|
||||
|
||||
spawn(async move {
|
||||
let skin_bytes =
|
||||
if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) {
|
||||
polyio::read(&skin_path).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let bytes_to_save = match skin_bytes {
|
||||
Some(b) => b,
|
||||
None => crate::AppAssets::get_bytes("steve.png")
|
||||
.map(|b| b.to_vec())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
if let Some(handle) = rfd::AsyncFileDialog::new()
|
||||
.set_title("Save Skin PNG")
|
||||
.set_file_name(format!("{uname}_skin.png"))
|
||||
.add_filter("PNG Image (*.png)", &["png"])
|
||||
.save_file()
|
||||
.await
|
||||
{
|
||||
if polyio::write(handle.path(), &bytes_to_save).await.is_ok() {
|
||||
status_dl.set(Some(("Skin exported successfully!".to_string(), false)));
|
||||
} else {
|
||||
status_dl.set(Some(("Failed to save skin file".to_string(), true)));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Remove / Reset skin
|
||||
let id_for_reset = account_id;
|
||||
let uname_for_reset = username_opt.clone();
|
||||
let mut status_reset = status_msg;
|
||||
|
||||
let reset_skin = move |_| {
|
||||
let Some(id) = id_for_reset else { return };
|
||||
let uname = uname_for_reset.clone();
|
||||
let uuid_str = id.to_string();
|
||||
|
||||
spawn(async move {
|
||||
let _ = delete_account_skin(&uuid_str, uname.as_deref()).await;
|
||||
status_reset.set(Some(("Skin reset to default Steve/Alex".to_string(), false)));
|
||||
});
|
||||
};
|
||||
|
||||
let mut open_url_modal = show_url_modal;
|
||||
let open_from_url = move |_| {
|
||||
open_url_modal.set(true);
|
||||
};
|
||||
|
||||
fn side_panel() -> impl IntoElement {
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::fill())
|
||||
.overflow(Overflow::Clip)
|
||||
.spacing(20.)
|
||||
.spacing(18.)
|
||||
.child(
|
||||
label()
|
||||
.text("Skins")
|
||||
.font_size(32.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
rect()
|
||||
.vertical()
|
||||
.spacing(4.)
|
||||
.child(
|
||||
label()
|
||||
.text("Skin Manager")
|
||||
.font_size(32.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format!("Managing skin for: {username}"))
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.maybe_child((*status_msg.read()).as_ref().map(|(msg, is_err)| {
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.padding(Gaps::new_symmetric(8., 12.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(if *is_err {
|
||||
colors::danger().with_a(30)
|
||||
} else {
|
||||
colors::success().with_a(30)
|
||||
})
|
||||
.border(border_all_color(
|
||||
1.,
|
||||
if *is_err {
|
||||
colors::danger()
|
||||
} else {
|
||||
colors::success()
|
||||
},
|
||||
))
|
||||
.child(
|
||||
Icon::new(if *is_err {
|
||||
IconType::AlertTriangle
|
||||
} else {
|
||||
IconType::CheckCircle
|
||||
})
|
||||
.size(16.)
|
||||
.color(if *is_err {
|
||||
colors::danger()
|
||||
} else {
|
||||
colors::success()
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(msg.clone())
|
||||
.font_size(13.)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.into_element()
|
||||
}))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.spacing(12.)
|
||||
.child(action_button("Import Skin", IconType::Plus, true))
|
||||
.child(action_button("Download", IconType::Download01, false))
|
||||
.child(action_button("Remove", IconType::Trash01, false)),
|
||||
.content(Content::wrap_spacing(8.))
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(import_from_file)
|
||||
.child(Icon::new(IconType::FilePlus02).size(16.))
|
||||
.text("Upload File (.png)"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.on_press(open_from_url)
|
||||
.child(Icon::new(IconType::Globe01).size(16.))
|
||||
.text("From Username / URL"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.on_press(download_skin)
|
||||
.child(Icon::new(IconType::Download01).size(16.))
|
||||
.text("Export Skin"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.on_press(reset_skin)
|
||||
.child(Icon::new(IconType::Trash01).size(16.))
|
||||
.text("Reset Skin"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.spacing(8.)
|
||||
.child(section_label("SWITCH ACCOUNT"))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::wrap_spacing(8.))
|
||||
.spacing(8.)
|
||||
.children(accounts.into_iter().map(|acc| {
|
||||
let id = acc.id;
|
||||
let is_sel = selected_id.read().map_or(false, |sel| sel == id);
|
||||
let mut sel_state = selected_id;
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(6.)
|
||||
.padding(Gaps::new_symmetric(6., 12.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(if is_sel {
|
||||
colors::brand().with_a(50)
|
||||
} else {
|
||||
colors::page_elevated()
|
||||
})
|
||||
.border(border_all_color(
|
||||
1.,
|
||||
if is_sel {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_border()
|
||||
},
|
||||
))
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_press(move |_| sel_state.set(Some(id)))
|
||||
.child(
|
||||
label()
|
||||
.text(acc.username)
|
||||
.font_size(13.)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.into_element()
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.spacing(10.)
|
||||
.padding(Gaps::new_all(14.))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.child(
|
||||
label()
|
||||
.text("💡 Offline Skin Tips")
|
||||
.font_size(14.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("• Supports all standard Minecraft skins (64x64 or legacy 64x32 PNGs).")
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("• Interactive 3D preview: Click & drag on the character to rotate!")
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("• Skins automatically sync with CustomSkinLoader for in-game rendering on offline servers.")
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(section_label("SKIN HISTORY"))
|
||||
.child(skin_grid(8))
|
||||
.child(section_label("CAPES"))
|
||||
.child(skin_grid(4))
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement {
|
||||
Button::new()
|
||||
.variant(if primary {
|
||||
ButtonVariant::Primary
|
||||
} else {
|
||||
ButtonVariant::Secondary
|
||||
})
|
||||
.child(Icon::new(icon).size(16.).color(colors::fg_primary()))
|
||||
.text(text)
|
||||
fn url_modal(
|
||||
selected_id: State<Option<uuid::Uuid>>,
|
||||
status_msg: State<Option<(String, bool)>>,
|
||||
show_url_modal: State<bool>,
|
||||
is_loading: State<bool>,
|
||||
) -> impl IntoElement {
|
||||
let input_text = use_state(String::new);
|
||||
let mut modal_open = show_url_modal;
|
||||
let mut status = status_msg;
|
||||
let mut loading = is_loading;
|
||||
|
||||
let target_uuid = (*selected_id.peek()).map(|id| id.to_string());
|
||||
|
||||
let apply_url = move |_| {
|
||||
let Some(ref uuid_str) = target_uuid else {
|
||||
return;
|
||||
};
|
||||
let uuid_str = uuid_str.clone();
|
||||
let query = input_text.peek().trim().to_string();
|
||||
if query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.set(true);
|
||||
modal_open.set(false);
|
||||
|
||||
spawn(async move {
|
||||
match fetch_skin_online(&query).await {
|
||||
Ok((bytes, is_slim)) => {
|
||||
let _ = save_account_skin(&uuid_str, None, &bytes, is_slim).await;
|
||||
status.set(Some((
|
||||
format!("Skin for \"{query}\" applied successfully!"),
|
||||
false,
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
status.set(Some((err, true)));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
OverlayPopup::new()
|
||||
.position(Position::new_global().top(140.).left(280.))
|
||||
.on_close(move |()| modal_open.set(false))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(380.))
|
||||
.padding(Gaps::new_all(20.))
|
||||
.spacing(16.)
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(140, 0, 0, 0))))
|
||||
.child(
|
||||
label()
|
||||
.text("Import Skin by Username or URL")
|
||||
.font_size(16.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("Enter any Minecraft player name (e.g. Technoblade, Notch) or a direct skin image URL:")
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
TextInput::new(input_text)
|
||||
.placeholder("Username (e.g. Dream) or https://...")
|
||||
.width(Size::fill()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.main_align(Alignment::End)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.on_press(move |_| modal_open.set(false))
|
||||
.text("Cancel"),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.on_press(apply_url)
|
||||
.text("Apply Skin"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
|
|
@ -136,34 +608,3 @@ fn section_label(text: &'static str) -> impl IntoElement {
|
|||
.color(colors::fg_secondary())
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn skin_grid(count: usize) -> impl IntoElement {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(12.)
|
||||
.children((0..count).map(|i| {
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::px(64.))
|
||||
.height(Size::px(80.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(
|
||||
if i == 0 { 2. } else { 1. },
|
||||
if i == 0 {
|
||||
colors::brand()
|
||||
} else {
|
||||
colors::component_border()
|
||||
},
|
||||
))
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.child(
|
||||
Icon::new(IconType::Users01)
|
||||
.size(24.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}))
|
||||
.into_element()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ pub fn logs_dir() -> PathsResult<PathBuf> {
|
|||
Ok(launcher_dir()?.join("logs"))
|
||||
}
|
||||
|
||||
pub fn skins_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("skins"))
|
||||
}
|
||||
|
||||
pub fn skin_file_path(uuid: &str) -> PathsResult<PathBuf> {
|
||||
Ok(skins_dir()?.join(format!("{uuid}.png")))
|
||||
}
|
||||
|
||||
pub fn skin_meta_path(uuid: &str) -> PathsResult<PathBuf> {
|
||||
Ok(skins_dir()?.join(format!("{uuid}.json")))
|
||||
}
|
||||
|
||||
pub fn java_dir() -> PathsResult<PathBuf> {
|
||||
Ok(launcher_dir()?.join("metadata").join("java"))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue