feat: initial CAPE support!

This commit is contained in:
neoapps-dev 2026-04-16 18:42:28 +03:00
parent 7ab84170c4
commit b13260ed05
3 changed files with 708 additions and 191 deletions

View file

@ -1,8 +1,13 @@
import { useState, useEffect, useRef, memo } from 'react';
import { motion } from 'framer-motion';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { TauriService } from '../../services/TauriService';
import { useUI, useAudio, useSkin, useConfig } from '../../context/LauncherContext';
import { useState, useEffect, useRef, memo } from "react";
import { motion } from "framer-motion";
import { useLocalStorage } from "../../hooks/useLocalStorage";
import { TauriService } from "../../services/TauriService";
import {
useUI,
useAudio,
useSkin,
useConfig,
} from "../../context/LauncherContext";
interface SavedSkin {
id: string;
@ -11,37 +16,78 @@ interface SavedSkin {
isSlim?: boolean;
}
interface SavedCape {
id: string;
name: string;
url: string;
}
const DEFAULT_SKINS: SavedSkin[] = [
{ id: 'default', name: 'Default Steve', url: '/images/Default.png', isSlim: false },
{ id: 'neoapps', name: 'neoapps', url: '/Skins/neoapps.png', isSlim: false },
{ id: 'justneki', name: 'JustNeki', url: '/Skins/JustNeki.png', isSlim: false },
{ id: 'kayjann', name: 'KayJann', url: '/Skins/KayJann.png', isSlim: false },
{ id: 'leon', name: 'Leon', url: '/Skins/Leon.png', isSlim: false },
{ id: 'mr_anilex', name: 'mr_anilex', url: '/Skins/mr_anilex.png', isSlim: false },
{ id: 'peter', name: 'Peter', url: '/Skins/Peter.png', isSlim: false },
{ id: 'piebot', name: 'piebot', url: '/Skins/piebot.png', isSlim: false }
{
id: "default",
name: "Default Steve",
url: "/images/Default.png",
isSlim: false,
},
{ id: "neoapps", name: "neoapps", url: "/Skins/neoapps.png", isSlim: false },
{
id: "justneki",
name: "JustNeki",
url: "/Skins/JustNeki.png",
isSlim: false,
},
{ id: "kayjann", name: "KayJann", url: "/Skins/KayJann.png", isSlim: false },
{ id: "leon", name: "Leon", url: "/Skins/Leon.png", isSlim: false },
{
id: "mr_anilex",
name: "mr_anilex",
url: "/Skins/mr_anilex.png",
isSlim: false,
},
{ id: "peter", name: "Peter", url: "/Skins/Peter.png", isSlim: false },
{ id: "piebot", name: "piebot", url: "/Skins/piebot.png", isSlim: false },
];
const SkinsView = memo(function SkinsView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { skinUrl, setSkinUrl, setSkinIsSlim } = useSkin();
const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"skin" | "cape">("skin");
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const capeFileInputRef = useRef<HTMLInputElement>(null);
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>('lce-custom-skins', []);
const savedSkins = [...DEFAULT_SKINS, ...storedSkins.filter(s => !DEFAULT_SKINS.some(d => d.id === s.id))];
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>(
"lce-custom-skins",
[],
);
const savedSkins = [
...DEFAULT_SKINS,
...storedSkins.filter((s) => !DEFAULT_SKINS.some((d) => d.id === s.id)),
];
const [storedCapes, setStoredCapes] = useLocalStorage<SavedCape[]>(
"lce-custom-capes",
[],
);
const [activeCapeId, setActiveCapeId] = useState<string | null>(null);
const TOP_BUTTONS_COUNT = 3; // Import, Delete, Folder
const TOP_BUTTONS_COUNT = viewMode === "skin" ? 3 : 3;
const SKINS_START_INDEX = TOP_BUTTONS_COUNT;
const BACK_BUTTON_INDEX = SKINS_START_INDEX + savedSkins.length;
const BACK_BUTTON_INDEX =
SKINS_START_INDEX +
(viewMode === "skin" ? savedSkins.length : storedCapes.length);
const ITEM_COUNT = BACK_BUTTON_INDEX + 1;
const setSavedSkins = (newSkins: SavedSkin[] | ((val: SavedSkin[]) => SavedSkin[])) => {
const updatedSkins = typeof newSkins === 'function' ? newSkins(savedSkins) : newSkins;
const customOnes = updatedSkins.filter(s => !DEFAULT_SKINS.some(d => d.id === s.id));
const setSavedSkins = (
newSkins: SavedSkin[] | ((val: SavedSkin[]) => SavedSkin[]),
) => {
const updatedSkins =
typeof newSkins === "function" ? newSkins(savedSkins) : newSkins;
const customOnes = updatedSkins.filter(
(s) => !DEFAULT_SKINS.some((d) => d.id === s.id),
);
setStoredSkins(customOnes);
};
@ -49,15 +95,20 @@ const SkinsView = memo(function SkinsView() {
const [showImportModal, setShowImportModal] = useState(false);
const [modalFocusIndex, setModalFocusIndex] = useState(0);
const [importMode, setImportMode] = useState<'file' | 'username' | 'model' | null>(null);
const [importUsername, setImportUsername] = useState('');
const [importMode, setImportMode] = useState<
"file" | "username" | "model" | "cape" | null
>(null);
const [importUsername, setImportUsername] = useState("");
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState('');
const [pendingSkin, setPendingSkin] = useState<{ url: string, defaultName: string } | null>(null);
const [importError, setImportError] = useState("");
const [pendingSkin, setPendingSkin] = useState<{
url: string;
defaultName: string;
} | null>(null);
const processSkinImage = (url: string, defaultName: string) => {
setPendingSkin({ url, defaultName });
setImportMode('model');
setImportMode("model");
setModalFocusIndex(0);
};
@ -74,8 +125,13 @@ const SkinsView = memo(function SkinsView() {
ctx.drawImage(img, 0, 0);
const base64String = cvs.toDataURL("image/png");
const newId = Date.now().toString();
const newSkin = { id: newId, name: pendingSkin.defaultName, url: base64String, isSlim };
setSavedSkins(prev => [...prev, newSkin]);
const newSkin = {
id: newId,
name: pendingSkin.defaultName,
url: base64String,
isSlim,
};
setSavedSkins((prev) => [...prev, newSkin]);
setSkinUrl(base64String);
setSkinIsSlim(isSlim);
setActiveSkinId(newId);
@ -92,13 +148,19 @@ const SkinsView = memo(function SkinsView() {
if (!importUsername.trim()) return;
playPressSound();
setIsImporting(true);
setImportError('');
setImportError("");
try {
const [base64Raw, exactName] = await TauriService.fetchSkin(importUsername.trim());
const skinBase64 = `data:image/png;base64,${base64Raw}`;
processSkinImage(skinBase64, exactName.substring(0, 16));
if (viewMode === "skin") {
const [base64Raw, exactName] = await TauriService.fetchSkin(
importUsername.trim(),
);
const skinBase64 = `data:image/png;base64,${base64Raw}`;
processSkinImage(skinBase64, exactName.substring(0, 16));
}
} catch (e: any) {
setImportError(typeof e === 'string' ? e : (e.message || 'Failed to fetch skin'));
setImportError(
typeof e === "string" ? e : e.message || "Failed to fetch",
);
} finally {
setIsImporting(false);
}
@ -106,50 +168,63 @@ const SkinsView = memo(function SkinsView() {
useEffect(() => {
if (!activeSkinId) {
const match = savedSkins.find(s => s.url === skinUrl);
const match = savedSkins.find((s) => s.url === skinUrl);
if (match) setActiveSkinId(match.id);
}
}, [activeSkinId, savedSkins, skinUrl]);
useEffect(() => {
if (!activeCapeId) {
const match = storedCapes.find((c) => c.url === capeUrl);
if (match) setActiveCapeId(match.id);
}
}, [activeCapeId, storedCapes, capeUrl]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (showImportModal) {
if (e.key === 'Escape') {
if (e.key === "Escape") {
playBackSound();
if (importMode) {
setImportMode(null);
setImportUsername('');
setImportError('');
setImportUsername("");
setImportError("");
setModalFocusIndex(0);
} else {
setShowImportModal(false);
setModalFocusIndex(0);
}
} else if (e.key === 'ArrowDown' || e.key === 'Tab') {
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setModalFocusIndex(prev => (prev + 1) % 3);
} else if (e.key === 'ArrowUp') {
setModalFocusIndex((prev) => (prev + 1) % 3);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setModalFocusIndex(prev => (prev - 1 + 3) % 3);
} else if (e.key === 'Enter') {
setModalFocusIndex((prev) => (prev - 1 + 3) % 3);
} else if (e.key === "Enter") {
if (!importMode) {
if (modalFocusIndex === 0) { playPressSound(); fileInputRef.current?.click(); }
else if (modalFocusIndex === 1) { playPressSound(); setImportMode('username'); setModalFocusIndex(0); }
else if (modalFocusIndex === 2) {
if (modalFocusIndex === 0) {
playPressSound();
fileInputRef.current?.click();
} else if (modalFocusIndex === 1) {
playPressSound();
setImportMode("username");
setModalFocusIndex(0);
} else if (modalFocusIndex === 2) {
playBackSound();
setShowImportModal(false);
setModalFocusIndex(0);
}
} else if (importMode === 'username') {
if (modalFocusIndex === 0 || modalFocusIndex === 1) handleFetchUsername();
} else if (importMode === "username") {
if (modalFocusIndex === 0 || modalFocusIndex === 1)
handleFetchUsername();
else if (modalFocusIndex === 2) {
playBackSound();
setImportMode(null);
setImportUsername('');
setImportError('');
setImportUsername("");
setImportError("");
setModalFocusIndex(0);
}
} else if (importMode === 'model') {
} else if (importMode === "model") {
if (modalFocusIndex === 0) handleFinalizeImport(false);
else if (modalFocusIndex === 1) handleFinalizeImport(true);
else if (modalFocusIndex === 2) {
@ -162,52 +237,80 @@ const SkinsView = memo(function SkinsView() {
}
return;
}
if (document.activeElement?.tagName === 'INPUT') return;
if (e.key === 'Escape') {
if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "Escape") {
playBackSound();
setActiveView('main');
setActiveView("main");
return;
}
if (e.key === 'ArrowRight') {
setFocusIndex(prev => (prev === null || prev >= ITEM_COUNT - 1) ? 0 : prev + 1);
} else if (e.key === 'ArrowLeft') {
setFocusIndex(prev => (prev === null || prev <= 0) ? ITEM_COUNT - 1 : prev - 1);
} else if (e.key === 'ArrowDown') {
if (e.key === "ArrowRight") {
setFocusIndex((prev) =>
prev === null || prev >= ITEM_COUNT - 1 ? 0 : prev + 1,
);
} else if (e.key === "ArrowLeft") {
setFocusIndex((prev) =>
prev === null || prev <= 0 ? ITEM_COUNT - 1 : prev - 1,
);
} else if (e.key === "ArrowDown") {
if (focusIndex === null || focusIndex < TOP_BUTTONS_COUNT) {
setFocusIndex(SKINS_START_INDEX);
} else if (focusIndex < BACK_BUTTON_INDEX) {
const next = focusIndex + 4;
const rowCount = viewMode === "cape" ? 3 : 4;
const next = focusIndex + rowCount;
setFocusIndex(next >= BACK_BUTTON_INDEX ? BACK_BUTTON_INDEX : next);
}
} else if (e.key === 'ArrowUp') {
} else if (e.key === "ArrowUp") {
if (focusIndex === null) {
setFocusIndex(0);
} else if (focusIndex === BACK_BUTTON_INDEX) {
setFocusIndex(SKINS_START_INDEX + savedSkins.length - 1);
const itemCount =
viewMode === "cape" ? storedCapes.length + 1 : savedSkins.length;
setFocusIndex(SKINS_START_INDEX + itemCount - 1);
} else if (focusIndex >= SKINS_START_INDEX) {
const next = focusIndex - 4;
const rowCount = viewMode === "cape" ? 3 : 4;
const next = focusIndex - rowCount;
setFocusIndex(next < SKINS_START_INDEX ? 0 : next);
}
} else if (e.key === 'Enter' && focusIndex !== null) {
if (focusIndex === 0) handleImportClick();
else if (focusIndex === 1) handleDeleteActive();
else if (focusIndex === 2) { playPressSound(); TauriService.openInstanceFolder('Skins').catch(() => { }); }
else if (focusIndex < BACK_BUTTON_INDEX) {
} else if (e.key === "Enter" && focusIndex !== null) {
if (focusIndex === 0) {
if (viewMode === "skin") handleImportClick();
else capeFileInputRef.current?.click();
} else if (focusIndex === 1) {
if (viewMode === "skin") handleDeleteActive();
else handleDeleteActiveCape();
} else if (focusIndex === 2) {
playPressSound();
setViewMode(viewMode === "skin" ? "cape" : "skin");
} else if (focusIndex < BACK_BUTTON_INDEX) {
handleSkinSelect(savedSkins[focusIndex - SKINS_START_INDEX]);
} else {
playBackSound();
setActiveView('main');
setActiveView("main");
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [focusIndex, savedSkins.length, playBackSound, setActiveView, playPressSound, showImportModal, importMode, modalFocusIndex, importUsername]);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
focusIndex,
savedSkins.length,
storedCapes.length,
playBackSound,
setActiveView,
playPressSound,
showImportModal,
importMode,
modalFocusIndex,
importUsername,
viewMode,
]);
useEffect(() => {
if (focusIndex !== null) {
const el = containerRef.current?.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement;
const el = containerRef.current?.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
if (el) el.focus();
}
}, [focusIndex]);
@ -221,16 +324,16 @@ const SkinsView = memo(function SkinsView() {
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== 'image/png') return;
if (file.type !== "image/png") return;
const defaultName = file.name.replace('.png', '').substring(0, 16);
const defaultName = file.name.replace(".png", "").substring(0, 16);
const reader = new FileReader();
reader.onload = (event) => {
const url = event.target?.result as string;
processSkinImage(url, defaultName);
};
reader.readAsDataURL(file);
e.target.value = '';
e.target.value = "";
};
const handleSkinSelect = (skin: SavedSkin) => {
@ -240,55 +343,150 @@ const SkinsView = memo(function SkinsView() {
setSkinIsSlim(skin.isSlim || false);
};
const isDefaultSkin = (id: string | null) => DEFAULT_SKINS.some(d => d.id === id);
const isDefaultSkin = (id: string | null) =>
DEFAULT_SKINS.some((d) => d.id === id);
const handleDeleteActive = () => {
if (!activeSkinId || isDefaultSkin(activeSkinId)) return;
playPressSound();
const updatedSkins = savedSkins.filter(s => s.id !== activeSkinId);
const updatedSkins = savedSkins.filter((s) => s.id !== activeSkinId);
setSavedSkins(updatedSkins);
setSkinUrl('/images/Default.png');
setSkinUrl("/images/Default.png");
setSkinIsSlim(false);
setActiveSkinId('default');
setActiveSkinId("default");
};
const handleNameChange = (id: string, newName: string) => {
const updatedSkins = savedSkins.map(s => s.id === id ? { ...s, name: newName } : s);
const updatedSkins = savedSkins.map((s) =>
s.id === id ? { ...s, name: newName } : s,
);
setSavedSkins(updatedSkins);
};
const isActiveDefault = isDefaultSkin(activeSkinId) || (!activeSkinId && skinUrl === '/images/Default.png');
const handleCapeFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== "image/png") return;
const defaultName = file.name.replace(".png", "").substring(0, 16);
const reader = new FileReader();
reader.onload = (event) => {
const url = event.target?.result as string;
const newId = Date.now().toString();
const newCape: SavedCape = { id: newId, name: defaultName, url };
setStoredCapes((prev) => [...prev, newCape]);
setCapeUrl(url);
setActiveCapeId(newId);
};
reader.readAsDataURL(file);
e.target.value = "";
};
const handleCapeSelect = (cape: SavedCape) => {
playPressSound();
setActiveCapeId(cape.id);
setCapeUrl(cape.url);
};
const handleDeleteActiveCape = () => {
if (!activeCapeId) return;
playPressSound();
const updatedCapes = storedCapes.filter((c) => c.id !== activeCapeId);
setStoredCapes(updatedCapes);
setCapeUrl(null);
setActiveCapeId(null);
};
const handleCapeNameChange = (id: string, newName: string) => {
const updatedCapes = storedCapes.map((c) =>
c.id === id ? { ...c, name: newName } : c,
);
setStoredCapes(updatedCapes);
};
const isActiveDefault =
isDefaultSkin(activeSkinId) ||
(!activeSkinId && skinUrl === "/images/Default.png");
const isActiveCapeDefault = !activeCapeId && !capeUrl;
return (
<motion.div ref={containerRef} tabIndex={-1} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }} className="flex flex-col items-center w-full max-w-3xl outline-none">
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">Skin Library</h2>
<div className="w-full max-w-160 h-85 mb-4 p-5 shadow-2xl flex flex-col relative" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<motion.div
ref={containerRef}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-3xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">
{viewMode === "skin" ? "Skin Library" : "Cape Library"}
</h2>
<div
className="w-full max-w-160 h-85 mb-4 p-5 shadow-2xl flex flex-col relative"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<div className="w-full flex items-center border-b-2 border-[#373737] pb-4 mb-4 relative min-h-10">
<div className="absolute left-0 right-0 flex justify-center gap-4 items-center">
<button
data-index="0"
onMouseEnter={() => setFocusIndex(0)}
onClick={handleImportClick}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: focusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
onClick={() => {
playPressSound();
if (viewMode === "skin") handleImportClick();
else capeFileInputRef.current?.click();
}}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
focusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Import Skin
{viewMode === "skin" ? "Import Skin" : "Import Cape"}
</button>
<button
data-index="1"
onMouseEnter={() => !isActiveDefault && setFocusIndex(1)}
onClick={handleDeleteActive}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${isActiveDefault ? 'text-gray-400 opacity-80 cursor-not-allowed' : (focusIndex === 1 ? 'text-[#FFFF55]' : 'text-white')}`}
onMouseEnter={() => {
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
else if (viewMode === "cape" && !isActiveCapeDefault)
setFocusIndex(1);
}}
onClick={() => {
playPressSound();
if (viewMode === "skin") handleDeleteActive();
else handleDeleteActiveCape();
}}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isActiveCapeDefault)
? "text-gray-400 opacity-80 cursor-not-allowed"
: focusIndex === 1
? "text-[#FFFF55]"
: "text-white"
}`}
style={{
backgroundImage: isActiveDefault ? "url('/images/Button_Background2.png')" : (focusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')"),
backgroundSize: '100% 100%',
imageRendering: 'pixelated'
backgroundImage:
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isActiveCapeDefault)
? "url('/images/Button_Background2.png')"
: focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Delete Skin
{viewMode === "skin" ? "Delete Skin" : "Delete Cape"}
</button>
</div>
@ -297,82 +495,276 @@ const SkinsView = memo(function SkinsView() {
<button
data-index="2"
onMouseEnter={() => setFocusIndex(2)}
onClick={() => { playPressSound(); TauriService.openInstanceFolder('Skins').catch(() => { }); }}
onClick={() => {
playPressSound();
setViewMode(viewMode === "skin" ? "cape" : "skin");
}}
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
style={{ backgroundImage: focusIndex === 2 ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
style={{
backgroundImage:
focusIndex === 2
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img src="/images/Folder_Icon.png" alt="Skins Folder" className="w-8 h-8 object-contain pointer-events-none drop-shadow-md" style={{ imageRendering: 'pixelated' }} loading="lazy" decoding="async" />
<img
src="/images/Update_Icon.png"
alt={viewMode === "skin" ? "Switch to Cape" : "Switch to Skin"}
className="w-8 h-8 object-contain pointer-events-none drop-shadow-md"
style={{ imageRendering: "pixelated" }}
loading="lazy"
decoding="async"
/>
</button>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept=".png" className="hidden" />
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".png"
className="hidden"
/>
<input
type="file"
ref={capeFileInputRef}
onChange={handleCapeFileChange}
accept=".png"
className="hidden"
/>
</div>
<div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-8 gap-y-6 items-start content-start justify-center">
{savedSkins.map((skin, i) => {
const idx = SKINS_START_INDEX + i;
const isActive = activeSkinId ? activeSkinId === skin.id : skinUrl === skin.url;
const isFocused = focusIndex === idx;
return (
<div key={skin.id} data-index={idx} tabIndex={0} onMouseEnter={() => setFocusIndex(idx)} className="flex flex-col items-center gap-1 w-32 outline-none">
{viewMode === "skin" ? (
savedSkins.map((skin, i) => {
const idx = SKINS_START_INDEX + i;
const isActive = activeSkinId
? activeSkinId === skin.id
: skinUrl === skin.url;
const isFocused = focusIndex === idx;
return (
<div
key={skin.id}
data-index={idx}
tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1 w-32 outline-none"
>
<div className="h-4 flex items-center justify-center gap-1">
{isActive && (
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
Active
</span>
)}
{skin.isSlim && (
<span className="bg-purple-500/50 border border-purple-500/80 text-white px-1 text-[10px] uppercase rounded">
Slim
</span>
)}
</div>
<div
onClick={() => handleSkinSelect(skin)}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
>
<img
src={skin.url}
draggable={false}
alt={skin.name}
className="absolute max-w-none"
style={{
width: "800%",
height: "auto",
left: "-100%",
top: "-100%",
imageRendering: "pixelated",
}}
loading="lazy"
decoding="async"
/>
</div>
<input
type="text"
value={skin.name}
maxLength={16}
onChange={(e) => handleNameChange(skin.id, e.target.value)}
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultSkin(skin.id) ? "pointer-events-none" : ""}`}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
readOnly={isDefaultSkin(skin.id)}
/>
</div>
);
})
) : (
<>
<div
data-index={SKINS_START_INDEX}
tabIndex={0}
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
className="flex flex-col items-center gap-1 w-32 outline-none"
>
<div className="h-4 flex items-center justify-center gap-1">
{isActive && <span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">Active</span>}
{skin.isSlim && <span className="bg-purple-500/50 border border-purple-500/80 text-white px-1 text-[10px] uppercase rounded">Slim</span>}
{isActiveCapeDefault && (
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
Active
</span>
)}
</div>
<div
onClick={() => handleSkinSelect(skin)}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${(isActive || isFocused) ? 'border-[#FFFF55]' : 'border-[#373737] hover:border-[#A0A0A0]'}`}
onClick={() => {
playPressSound();
setCapeUrl(null);
setActiveCapeId(null);
}}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
>
<img src={skin.url} draggable={false} alt={skin.name} className="absolute max-w-none" style={{ width: '800%', height: 'auto', left: '-100%', top: '-100%', imageRendering: 'pixelated' }} loading="lazy" decoding="async" />
<span className="text-gray-500 text-2xl">X</span>
</div>
<input
type="text" value={skin.name} maxLength={16}
onChange={(e) => handleNameChange(skin.id, e.target.value)}
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${(isActive || isFocused) ? 'text-[#FFFF55]' : 'text-white'} ${isDefaultSkin(skin.id) ? 'pointer-events-none' : ''}`}
onClick={(e) => e.stopPropagation()} spellCheck={false}
readOnly={isDefaultSkin(skin.id)}
/>
<span
className={`text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
>
No Cape
</span>
</div>
);
})}
{storedCapes.map((cape, i) => {
const idx = SKINS_START_INDEX + 1 + i;
const isActive = activeCapeId
? activeCapeId === cape.id
: capeUrl === cape.url;
const isFocused = focusIndex === idx;
return (
<div
key={cape.id}
data-index={idx}
tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1 w-32 outline-none"
>
<div className="h-4 flex items-center justify-center gap-1">
{isActive && (
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
Active
</span>
)}
</div>
<div
onClick={() => handleCapeSelect(cape)}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${isActive || isFocused ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
>
<img
src={cape.url}
draggable={false}
alt={cape.name}
className="absolute max-w-none"
style={{
width: "auto",
height: "200%",
left: "50%",
top: "-50%",
transform: "translateX(-50%)",
imageRendering: "pixelated",
}}
loading="lazy"
decoding="async"
/>
</div>
<input
type="text"
value={cape.name}
maxLength={16}
onChange={(e) =>
handleCapeNameChange(cape.id, e.target.value)
}
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"}`}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
/>
</div>
);
})}
</>
)}
</div>
</div>
<button
data-index={BACK_BUTTON_INDEX}
onMouseEnter={() => setFocusIndex(BACK_BUTTON_INDEX)}
onClick={() => { playBackSound(); setActiveView('main'); }}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none hover:text-[#FFFF55] ${focusIndex === BACK_BUTTON_INDEX ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: focusIndex === BACK_BUTTON_INDEX ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none hover:text-[#FFFF55] ${focusIndex === BACK_BUTTON_INDEX ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
focusIndex === BACK_BUTTON_INDEX
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Back
</button>
{showImportModal && (
{showImportModal && viewMode === "skin" && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="flex flex-col items-center bg-[#252525] p-6 border-4 border-[#373737] shadow-[0_0_20px_rgba(0,0,0,0.8)] relative" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", minWidth: '400px' }}>
<h2 className="text-2xl text-white mc-text-shadow mb-6 tracking-widest uppercase font-bold text-center">Import Skin</h2>
<div
className="flex flex-col items-center bg-[#252525] p-6 border-4 border-[#373737] shadow-[0_0_20px_rgba(0,0,0,0.8)] relative"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
minWidth: "400px",
}}
>
<h2 className="text-2xl text-white mc-text-shadow mb-6 tracking-widest uppercase font-bold text-center">
Import Skin
</h2>
{!importMode ? (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<button
onMouseEnter={() => setModalFocusIndex(0)}
onClick={() => { playPressSound(); fileInputRef.current?.click(); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
onClick={() => {
playPressSound();
fileInputRef.current?.click();
}}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
modalFocusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
From File
</button>
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={() => { playPressSound(); setImportMode('username'); setModalFocusIndex(0); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
onClick={() => {
playPressSound();
setImportMode("username");
setModalFocusIndex(0);
}}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
modalFocusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
From Username
</button>
</div>
) : importMode === 'username' ? (
) : importMode === "username" ? (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<input
type="text"
@ -382,37 +774,72 @@ const SkinsView = memo(function SkinsView() {
onFocus={() => setModalFocusIndex(0)}
autoFocus
spellCheck={false}
className={`w-full h-12 bg-black/50 border-2 text-white px-4 text-xl outline-none transition-colors ${modalFocusIndex === 0 ? 'border-[#FFFF55]' : 'border-[#373737]'}`}
className={`w-full h-12 bg-black/50 border-2 text-white px-4 text-xl outline-none transition-colors ${modalFocusIndex === 0 ? "border-[#FFFF55]" : "border-[#373737]"}`}
/>
{importError && <span className="text-red-400 text-sm text-center mc-text-shadow">{importError}</span>}
{importError && (
<span className="text-red-400 text-sm text-center mc-text-shadow">
{importError}
</span>
)}
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={handleFetchUsername}
disabled={isImporting}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${isImporting ? 'opacity-50' : (modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white')}`}
style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${isImporting ? "opacity-50" : modalFocusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
modalFocusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{isImporting ? 'Fetching...' : 'Fetch Skin'}
{isImporting
? "Fetching..."
: `Fetch ${viewMode === "skin" ? "Skin" : "Cape"}`}
</button>
</div>
) : importMode === 'model' ? (
) : importMode === "model" ? (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<span className="text-white/80 text-sm text-center mb-2 mc-text-shadow">Choose the player model type for this skin:</span>
<span className="text-white/80 text-sm text-center mb-2 mc-text-shadow">
Choose the player model type for this skin:
</span>
<button
onMouseEnter={() => setModalFocusIndex(0)}
onClick={() => { playPressSound(); handleFinalizeImport(false); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
onClick={() => {
playPressSound();
handleFinalizeImport(false);
}}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
modalFocusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Normal (Wide Arms)
</button>
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={() => { playPressSound(); handleFinalizeImport(true); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
onClick={() => {
playPressSound();
handleFinalizeImport(true);
}}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
modalFocusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Slim (Thin Arms)
</button>
@ -425,12 +852,19 @@ const SkinsView = memo(function SkinsView() {
playBackSound();
setShowImportModal(false);
setImportMode(null);
setImportUsername('');
setImportError('');
setImportUsername("");
setImportError("");
setModalFocusIndex(0);
}}
className={`w-40 h-10 flex items-center justify-center transition-colors text-lg mc-text-shadow mt-6 outline-none ${modalFocusIndex === 2 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 2 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
className={`w-40 h-10 flex items-center justify-center transition-colors text-lg mc-text-shadow mt-6 outline-none ${modalFocusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage:
modalFocusIndex === 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>

View file

@ -14,9 +14,17 @@ interface UseSkinSyncProps {
}
export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
const [skinUrl, setSkinUrl] = useLocalStorage("lce-skin", "/images/Default.png");
const [skinUrl, setSkinUrl] = useLocalStorage(
"lce-skin",
"/images/Default.png",
);
const [skinIsSlim, setSkinIsSlim] = useLocalStorage("lce-skin-slim", false);
const [skinBase64, setSkinBase64] = useState<string | null>(null);
const [capeUrl, setCapeUrl] = useLocalStorage<string | null>(
"lce-cape",
null,
);
const [capeBase64, setCapeBase64] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!skinUrl) return;
@ -39,59 +47,125 @@ export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
setSkinBase64(b64);
try {
const res = await fetch(b64);
const buf = await res.arrayBuffer();
const skinBuf = await res.arrayBuffer();
const isModernHeight = img.height === 64;
const animValue = skinIsSlim ? "0x00080000" : (isModernHeight ? "0x00040000" : "0x00000000");
const animValue = skinIsSlim
? "0x00080000"
: isModernHeight
? "0x00040000"
: "0x00000000";
let boxes: PCKProperty[] = [];
/*if (skinIsSlim) {
if (skinIsSlim) {
boxes.push({
key: "BOX",
value: "ARM0 -2 1 -1 3 10 3 40 16 0 0 0"
value: "ARM0 -2 1 -1 3 10 3 40 16 0 0 0",
});
boxes.push({
key: "BOX",
value: "ARM1 -1 1 -1 3 10 3 52 16 0 0 0"
value: "ARM1 -1 1 -1 3 10 3 52 16 0 0 0",
});
}*/
const pckBuf = PckService.serializePCK({
version: 3,
endianness: "little",
xmlSupport: false,
properties: ["ANIM", "DISPLAYNAME", "THEMENAME", "GAME_FLAGS", "FREE", "BOX"],
files: [{
}
let capeBuf: ArrayBuffer | null = null;
if (capeUrl) {
const capeImg = new Image();
capeImg.crossOrigin = "anonymous";
await new Promise<void>((resolve) => {
capeImg.onload = () => resolve();
capeImg.onerror = () => resolve();
capeImg.src = capeUrl;
});
if (!cancelled && capeImg.complete && capeImg.naturalWidth > 0) {
const capeCanvas = document.createElement("canvas");
capeCanvas.width = capeImg.naturalWidth;
capeCanvas.height = capeImg.naturalHeight;
const capeCtx = capeCanvas.getContext("2d");
if (capeCtx) {
capeCtx.drawImage(capeImg, 0, 0);
const capeDataUrl = capeCanvas.toDataURL("image/png");
const capeRes = await fetch(capeDataUrl);
capeBuf = await capeRes.arrayBuffer();
setCapeBase64(capeDataUrl);
}
}
} else {
setCapeBase64(null);
}
const files: any[] = [
{
id: "0",
path: "0",
type: PCKAssetType.INFO,
size: 0,
data: new Uint8Array(0),
properties: [{
key: "PACKID",
value: "9999"
}]
}, {
properties: [
{
key: "PACKID",
value: "9999",
},
],
},
{
id: "dlcskin00000001",
path: "dlcskin00000001.png",
type: PCKAssetType.SKIN,
size: buf.byteLength,
data: new Uint8Array(buf),
properties: [{
key: "DISPLAYNAME",
value: "Custom Skin"
}, {
key: "GAME_FLAGS",
value: "0x18"
}, {
key: "FREE",
value: "1"
},
{
key: "ANIM",
value: animValue
}, ...boxes, {
key: "THEMENAME",
value: "Emerald Launcher"
}]
}]
size: skinBuf.byteLength,
data: new Uint8Array(skinBuf),
properties: [
{
key: "DISPLAYNAME",
value: "Custom Skin",
},
{
key: "GAME_FLAGS",
value: "0x1c",
},
{
key: "FREE",
value: "1",
},
{
key: "ANIM",
value: animValue,
},
...boxes,
{
key: "THEMENAME",
value: "Emerald Launcher",
},
{
key: "CAPEPATH",
value: "dlccape00000001.png",
},
],
},
];
if (capeBuf) {
files.push({
id: "dlccape00000001",
path: "dlccape00000001.png",
type: PCKAssetType.CAPE,
size: capeBuf.byteLength,
data: new Uint8Array(capeBuf),
properties: [],
});
}
const pckBuf = PckService.serializePCK({
version: 3,
endianness: "little",
xmlSupport: false,
properties: [
"ANIM",
"DISPLAYNAME",
"THEMENAME",
"GAME_FLAGS",
"FREE",
"BOX",
],
files,
});
await TauriService.saveGlobalSkinPck(new Uint8Array(pckBuf));
} catch (e) {
@ -103,7 +177,7 @@ export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
return () => {
cancelled = true;
};
}, [skinUrl, profile, editions]);
}, [skinUrl, capeUrl, profile, editions, skinIsSlim]);
return {
skinUrl,
@ -111,5 +185,8 @@ export function useSkinSync({ profile, editions }: UseSkinSyncProps) {
skinIsSlim,
setSkinIsSlim,
skinBase64,
capeUrl,
setCapeUrl,
capeBase64,
};
}

View file

@ -124,8 +124,14 @@ export class TauriService {
return invoke("sync_dlc", { instanceId });
}
static async workshopInstall(instanceId: string, packageId: string, zips: Record<string, string>): Promise<void> {
return invoke("workshop_install", { request: { instanceId, packageId, zips } });
static async workshopInstall(
instanceId: string,
packageId: string,
zips: Record<string, string>,
): Promise<void> {
return invoke("workshop_install", {
request: { instanceId, packageId, zips },
});
}
static onDownloadProgress(callback: (percent: number) => void) {