Merge diamond into main (#175)

Co-authored-by: str1k3r <[email protected]>
This commit is contained in:
/home/neo
2026-08-17 21:42:38 +03:00
committed by GitHub
co-authored by str1k3r
parent 816997803a
commit d6f622c913
128 changed files with 5747 additions and 1513 deletions
+24
View File
@@ -144,13 +144,37 @@ const CapePreview = memo(function CapePreview({
render();
}
};
const onTouchStart = (e: TouchEvent) => {
const t = e.touches[0];
if (!t) return;
isDragging = true;
previousMousePosition = { x: t.clientX, y: t.clientY };
};
const onTouchMove = (e: TouchEvent) => {
const t = e.touches[0];
if (isDragging && groupRef.current && t) {
e.preventDefault();
groupRef.current.rotation.y +=
(t.clientX - previousMousePosition.x) * 0.01;
previousMousePosition = { x: t.clientX, y: t.clientY };
render();
}
};
const onTouchEnd = () => {
isDragging = false;
};
renderer.domElement.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
renderer.domElement.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onTouchEnd);
return () => {
active = false;
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
if (object.geometry) object.geometry.dispose();
+7 -95
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { TauriService } from "../../services/TauriService";
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
interface ScreenshotImageProps {
path: string;
className?: string;
@@ -9,50 +9,6 @@ interface ScreenshotImageProps {
fallbackSrc?: string;
}
const imgCache = new Map<string, string>();
let activeLoads = 0;
const MAX_CONCURRENT = 4;
const loadQueue: Array<() => void> = [];
function dequeue() {
while (activeLoads < MAX_CONCURRENT && loadQueue.length > 0) {
const next = loadQueue.shift()!;
activeLoads++;
next();
}
}
function enqueueLoad(
path: string,
onLoad: (url: string) => void,
onError: () => void,
) {
const cached = imgCache.get(path);
if (cached) {
onLoad(cached);
return;
}
const run = () => {
TauriService.readScreenshotAsDataUrl(path)
.then((url) => {
imgCache.set(path, url);
onLoad(url);
})
.catch(() => onError())
.finally(() => {
activeLoads--;
dequeue();
});
};
if (activeLoads < MAX_CONCURRENT) {
activeLoads++;
run();
} else {
loadQueue.push(run);
}
}
export function ScreenshotImage({
path,
className,
@@ -61,62 +17,18 @@ export function ScreenshotImage({
style,
fallbackSrc,
}: ScreenshotImageProps) {
const [src, setSrc] = useState<string | undefined>(fallbackSrc);
const imgRef = useRef<HTMLImageElement>(null);
const loadedRef = useRef(false);
useEffect(() => {
const el = imgRef.current?.parentElement || imgRef.current;
if (!el) return;
let cancelled = false;
const doLoad = () => {
if (loadedRef.current) return;
loadedRef.current = true;
enqueueLoad(
path,
(url) => {
if (!cancelled) setSrc(url);
},
() => {
if (!cancelled && fallbackSrc) setSrc(fallbackSrc);
},
);
};
if (loading === "eager") {
doLoad();
return () => {
cancelled = true;
};
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
observer.disconnect();
doLoad();
}
},
{ rootMargin: "800px" },
);
observer.observe(el);
return () => {
cancelled = true;
observer.disconnect();
};
}, [path, fallbackSrc, loading]);
const handleError = () => {
if (fallbackSrc) setSrc(fallbackSrc);
};
const [hasError, setHasError] = useState(false);
return (
<img
ref={imgRef}
src={src}
src={hasError && fallbackSrc ? fallbackSrc : convertFileSrc(path)}
className={className}
alt={alt}
loading={loading}
style={style}
onError={handleError}
onError={() => {
if (fallbackSrc) setHasError(true);
}}
/>
);
}
+41 -13
View File
@@ -16,6 +16,7 @@ interface SkinViewerProps {
onNavigateRight: () => void;
hideControls?: boolean;
style?: React.CSSProperties;
slim?: boolean;
}
const SkinViewer = memo(function SkinViewer({
@@ -30,6 +31,7 @@ const SkinViewer = memo(function SkinViewer({
onNavigateRight,
hideControls,
style,
slim,
}: SkinViewerProps) {
const mountRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -181,17 +183,19 @@ const SkinViewer = memo(function SkinViewer({
});
const isSlim =
!isLegacy &&
(() => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) return false;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(42, 48, 1, 1).data;
return data[3] === 0;
})();
slim !== undefined
? slim
: !isLegacy &&
(() => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) return false;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(42, 48, 1, 1).data;
return data[3] === 0;
})();
const armW = isSlim ? 3 : 4;
const headUv = {
top: [8, 0, 8, 8],
@@ -379,7 +383,7 @@ const SkinViewer = memo(function SkinViewer({
}
const name = username.toLowerCase();
playerGroup.rotation.y = -0.3;
playerGroup.rotation.y = 0.3;
if (name === "dinnerbone" || name === "grumm") {
playerGroup.scale.y = -1;
playerGroup.position.y = 1.5;
@@ -404,15 +408,39 @@ const SkinViewer = memo(function SkinViewer({
requestRenderRef.current?.();
}
};
const onTouchStart = (e: TouchEvent) => {
const t = e.touches[0];
if (!t) return;
isDragging = true;
previousMousePosition = { x: t.clientX, y: t.clientY };
};
const onTouchMove = (e: TouchEvent) => {
const t = e.touches[0];
if (isDragging && t) {
e.preventDefault();
const delta = (t.clientX - previousMousePosition.x) * 0.01;
playerGroup.rotation.y += delta;
previousMousePosition = { x: t.clientX, y: t.clientY };
requestRenderRef.current?.();
}
};
const onTouchEnd = () => {
isDragging = false;
};
requestRenderRef.current = () => renderer.render(scene, camera);
requestRenderRef.current();
renderer.domElement.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
renderer.domElement.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onTouchEnd);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
if (object.geometry) object.geometry.dispose();
@@ -437,7 +465,7 @@ const SkinViewer = memo(function SkinViewer({
easterEggRef.current = null;
requestRenderRef.current = null;
};
}, [skinUrl, capeUrl]);
}, [skinUrl, capeUrl, slim]);
useEffect(() => {
const group = playerGroupRef.current;
+511
View File
@@ -0,0 +1,511 @@
import { useState, useEffect, useMemo, useRef, type RefObject } from "react";
import { TauriService } from "../../services/TauriService";
import {
parseSchema,
mergeValues,
defaultValues,
computeEffects,
buildArgs,
type ArgsSchema,
type SchemaOption,
} from "../../utils/argsSchema";
export default function OptionsModal({
isOpen,
onClose,
playPressSound,
playBackSound,
instanceId,
instanceName,
savedValues,
onSave,
}: {
isOpen: boolean;
onClose: () => void;
playPressSound: (s?: string) => void;
playBackSound: (s?: string) => void;
instanceId: string;
instanceName: string;
savedValues?: Record<string, unknown>;
onSave: (
instanceId: string,
values: Record<string, unknown>,
args: string[],
) => void;
}) {
const [schema, setSchema] = useState<ArgsSchema | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const rowRefs = useRef<(HTMLElement | null)[]>([]);
const inputRefs = useRef<(HTMLElement | null)[]>([]);
const resetRef = useRef<HTMLButtonElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const saveRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
setSchema(null);
setValues({});
setFocusIndex(0);
TauriService.getInstanceArgsSchema(instanceId)
.then((raw) => {
if (cancelled) return;
if (!raw) {
setError("This instance does not provide a launch options schema.");
setLoading(false);
return;
}
const parsed = parseSchema(raw);
if (!parsed) {
setError("The launch options schema is invalid or unsupported.");
setLoading(false);
return;
}
setSchema(parsed);
setValues(mergeValues(parsed, savedValues));
setLoading(false);
})
.catch((e) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
cancelled = true;
};
}, [isOpen, instanceId, savedValues]);
const effects = useMemo(
() => (schema ? computeEffects(schema, values) : {}),
[schema, values],
);
const visibleOptions = useMemo(
() =>
schema
? schema.options.filter((o) => !effects[o.id]?.hidden)
: ([] as SchemaOption[]),
[schema, effects],
);
const optionOrder = useMemo(
() => visibleOptions.map((o) => o.id),
[visibleOptions],
);
useEffect(() => {
setFocusIndex((prev) => Math.min(prev, optionOrder.length + 2));
}, [optionOrder.length]);
const sections = useMemo(() => {
if (!schema)
return {
sections: [] as {
title: string;
description?: string;
options: SchemaOption[];
}[],
general: [] as SchemaOption[],
};
const byGroup = new Map<string, SchemaOption[]>();
const general: SchemaOption[] = [];
const declared = new Set(schema.groups.map((g) => g.id));
for (const option of visibleOptions) {
if (option.group && declared.has(option.group)) {
const list = byGroup.get(option.group);
if (list) list.push(option);
else byGroup.set(option.group, [option]);
} else {
general.push(option);
}
}
const sections = schema.groups
.map((group) => ({
title: group.title,
description: group.description,
options: byGroup.get(group.id) ?? ([] as SchemaOption[]),
}))
.filter((s) => s.options.length > 0);
return { sections, general };
}, [schema, visibleOptions]);
const handleReset = () => {
playPressSound();
if (!schema) return;
setValues(defaultValues(schema));
};
const handleSave = () => {
if (!schema) return;
playPressSound("save_click.wav");
const finalValues = { ...values };
onSave(
instanceId,
finalValues,
buildArgs(schema, finalValues, computeEffects(schema, finalValues)),
);
onClose();
};
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName;
if (
activeTag === "INPUT" ||
activeTag === "SELECT" ||
activeTag === "TEXTAREA"
) {
if (e.key === "Escape") {
playBackSound();
onClose();
}
return;
}
const total = optionOrder.length + 3;
if (e.key === "Escape") {
playBackSound();
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % total);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + total) % total);
} else if (e.key === "Enter") {
e.preventDefault();
if (focusIndex < optionOrder.length) {
const id = optionOrder[focusIndex];
const option = schema?.options.find((o) => o.id === id);
if (!option) return;
const effect = effects[id];
if (option.type === "boolean") {
if (!effect?.disabled) {
setValues((prev) => ({ ...prev, [id]: !prev[id] }));
}
} else {
const input = inputRefs.current[focusIndex];
if (input) input.focus();
}
} else if (focusIndex === optionOrder.length) {
handleReset();
} else if (focusIndex === optionOrder.length + 1) {
playBackSound();
onClose();
} else {
handleSave();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [
isOpen,
optionOrder,
focusIndex,
schema,
values,
effects,
playPressSound,
playBackSound,
onClose,
]);
useEffect(() => {
if (!isOpen) return;
if (focusIndex < optionOrder.length) {
rowRefs.current[focusIndex]?.focus();
} else if (focusIndex === optionOrder.length) {
resetRef.current?.focus();
} else if (focusIndex === optionOrder.length + 1) {
cancelRef.current?.focus();
} else {
saveRef.current?.focus();
}
}, [isOpen, focusIndex, optionOrder]);
if (!isOpen) return null;
const flatIndex = (optionId: string) => optionOrder.indexOf(optionId);
const titleDesc = (option: SchemaOption) => (
<div className="flex-1 min-w-0">
<div className="text-sm text-[#222222] mc-text-shadow truncate">
{option.title}
</div>
{option.description && (
<div className="text-[11px] text-[#666666] leading-tight">
{option.description}
</div>
)}
</div>
);
const control = (option: SchemaOption, index: number, disabled: boolean) => {
const value = values[option.id];
switch (option.type) {
case "boolean":
return null;
case "int":
case "number":
return (
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
type="number"
disabled={disabled}
min={option.min}
max={option.max}
step={option.step ?? (option.type === "int" ? 1 : "any")}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const raw = e.target.value;
setValues((prev) => ({
...prev,
[option.id]: raw === "" ? "" : Number(raw),
}));
}}
onFocus={() => setFocusIndex(index)}
className={`w-24 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none text-center font-['Mojangles'] focus:border-[#FFFF55] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{ imageRendering: "pixelated" }}
/>
);
case "string":
return (
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
disabled={disabled}
placeholder={option.placeholder}
value={typeof value === "string" ? value : ""}
onChange={(e) => {
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
}}
onFocus={() => setFocusIndex(index)}
className={`w-44 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{ imageRendering: "pixelated" }}
/>
);
case "choice":
return (
<select
ref={(el) => {
inputRefs.current[index] = el;
}}
disabled={disabled}
value={typeof value === "string" ? value : ""}
onChange={(e) => {
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
}}
onFocus={() => setFocusIndex(index)}
className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{ imageRendering: "pixelated" }}
>
{option.choices?.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label ?? choice.value}
</option>
))}
</select>
);
}
};
const renderOption = (option: SchemaOption) => {
const index = flatIndex(option.id);
const effect = effects[option.id];
const disabled = !!effect?.disabled;
const isFocused = focusIndex === index;
if (option.type === "boolean") {
return (
<button
key={option.id}
ref={(el) => {
rowRefs.current[index] = el;
inputRefs.current[index] = null;
}}
onFocus={() => setFocusIndex(index)}
onClick={() => {
if (disabled) return;
playPressSound();
setValues((prev) => ({ ...prev, [option.id]: !prev[option.id] }));
}}
className={`w-full flex items-center gap-3 px-3 py-2 text-left outline-none border-2 ${
isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="relative w-6 h-6 flex-shrink-0 flex items-center justify-center">
<img
src={
isFocused
? "/images/checkbox_highlighted.png"
: "/images/checkbox.png"
}
alt=""
className="absolute inset-0 w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
{values[option.id] === true && (
<img
src="/images/check.png"
alt=""
className="relative z-10 w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}
</div>
{titleDesc(option)}
</button>
);
}
return (
<div
key={option.id}
ref={(el) => {
rowRefs.current[index] = el;
}}
tabIndex={-1}
className={`w-full flex items-center gap-3 px-3 py-2 outline-none border-2 ${
isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent"
} ${disabled ? "opacity-50" : ""}`}
>
<div className="flex-1 min-w-0 flex items-center gap-3">
{control(option, index, disabled)}
{titleDesc(option)}
</div>
</div>
);
};
const actionButton = (
ref: RefObject<HTMLButtonElement | null>,
index: number,
label: string,
onClick: () => void,
danger?: boolean,
) => (
<button
ref={ref}
onMouseEnter={() => setFocusIndex(index)}
onClick={onClick}
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${
focusIndex === index
? "text-[#FFFF55]"
: danger
? "text-red-500"
: "text-white"
}`}
style={{
backgroundImage:
focusIndex === index
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{label}
</button>
);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 outline-none border-none"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
playBackSound();
onClose();
}
}}
>
<div className="relative w-[620px] max-w-[95vw] max-h-[88vh] p-5 flex flex-col items-center font-['Mojangles'] mc-options-bg">
<h2 className="text-xl text-black mc-text-shadow mb-1 text-center">
Options
</h2>
<p className="text-[#333333] text-sm mb-4 text-center truncate max-w-full">
{instanceName}
</p>
{loading ? (
<div className="flex flex-col items-center gap-4 py-10">
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
<p className="text-black text-lg mc-text-shadow">
Loading options...
</p>
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-red-600 text-sm mc-text-shadow text-center max-w-md">
{error}
</p>
<div className="flex gap-4 mt-2 w-full">
{actionButton(cancelRef, optionOrder.length + 1, "OK", () => {
playBackSound();
onClose();
})}
</div>
</div>
) : schema ? (
<>
<div className="w-full flex-1 min-h-0 max-h-[52vh] overflow-y-auto custom-scrollbar mb-4">
{sections.sections.map((section) => (
<div key={section.title} className="mb-3">
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
{section.title}
</h3>
{section.description && (
<p className="text-[#666666] text-xs px-3 pb-1">
{section.description}
</p>
)}
{section.options.map(renderOption)}
</div>
))}
{sections.general.length > 0 && (
<div className="mb-3">
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
General
</h3>
{sections.general.map(renderOption)}
</div>
)}
</div>
<div className="flex gap-4 w-full flex-shrink-0">
{actionButton(
resetRef,
optionOrder.length,
"Reset",
handleReset,
true,
)}
{actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => {
playBackSound();
onClose();
})}
{actionButton(
saveRef,
optionOrder.length + 2,
"Save",
handleSave,
)}
</div>
</>
) : null}
</div>
</div>
);
}
+97 -28
View File
@@ -1,6 +1,7 @@
import { useEffect, memo, useState } from "react";
import { useEffect, useMemo, memo, useState } from "react";
import { motion } from "framer-motion";
import { useUI, useAudio } from "../../context/LauncherContext";
import { usePlatform } from "../../hooks/usePlatform";
interface CreditCategory {
category: string;
@@ -31,7 +32,56 @@ interface CreditCategory {
const CreditsView = memo(function CreditsView() {
const { setActiveView } = useUI();
const { playPressSound } = useAudio();
const { isAndroid } = usePlatform();
const [isHovered, setIsHovered] = useState(false);
const shuffledAndroid = useMemo(
//neo: yes im shuffling it
() =>
[
"Peppinaramenisblack",
"kacper",
"tjdownchurch",
"tjdownchurch's dad (rip)",
"dadael3",
"bekhens",
"notrainbowsteve",
"moth_scribe",
"leader",
"raymanroy",
"fin",
"liugu",
"Zrox2013 (Zameras2013)",
"kierwa",
"cartox",
"necmi",
"theunknown",
"XeroChunks (double thanks!)",
"bee (quickjinxy_)",
"loss_less",
"ttfly",
"harper",
"dllie (tofou)",
"recycleordie",
"nezzled",
"frenchwith0skill",
"tarknim",
"thehuckle",
"gabrielblast",
"nedjouamario",
"bossanova",
"jayem",
"andrewjcf",
"thingthing",
"toastybaguette",
"goobert",
"Erickk64",
"flamingphoenex",
"Tymszn21",
"DaPogLord",
"turniphead",
].sort(() => Math.random() - 0.5),
[],
);
const credits: CreditCategory[] = [
{
@@ -96,10 +146,6 @@ const CreditsView = memo(function CreditsView() {
members: [
{ name: "Huckle", url: "https://github.com/TheHuckleDev" },
{ name: "Andi_pog", url: "https://github.com/Andi-pog" },
{
name: "LordCambion",
url: "https://github.com/LordCambion",
},
{ name: "neoapps", url: "https://github.com/neoapps-dev" },
{ name: "tranqlmao", url: "https://github.com/tranqlmao" },
],
@@ -111,6 +157,10 @@ const CreditsView = memo(function CreditsView() {
{ name: "Rockefeler", url: "#" },
{ name: "CDevJoud", url: "#" },
{ name: "Rhys Evolution", url: "#" },
{
name: "LordCambion",
url: "https://github.com/LordCambion",
},
],
},
],
@@ -172,12 +222,12 @@ const CreditsView = memo(function CreditsView() {
],
},
{
name: "Portable LCE",
icon: "",
name: "Project Lost Legacy",
icon: "/images/lostlegacy.png",
roles: [
{
role: "Founder",
members: [{ name: "TBD", url: "#" }],
members: [{ name: "SailsYT", url: "#" }],
},
],
},
@@ -197,6 +247,23 @@ const CreditsView = memo(function CreditsView() {
category: "SPECIAL THANKS",
icon: "",
subcategories: [
...(isAndroid
? [
{
name: "Android Beta Testers",
icon: "",
roles: [
{
role: "",
members: shuffledAndroid.map((name) => ({
name,
url: "#",
})),
},
],
},
]
: []),
{
name: "Discord Booster",
icon: "/images/Nitro Boost.png",
@@ -242,25 +309,27 @@ const CreditsView = memo(function CreditsView() {
return (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden">
<button
onClick={() => {
playPressSound();
setActiveView("main");
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="fixed bottom-8 left-8 z-50 h-10 flex items-center justify-center gap-2 px-4 text-xl mc-text-shadow outline-none border-none"
style={{
backgroundImage: isHovered
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
color: isHovered ? "#FFFF55" : "white",
}}
>
Back to Menu
</button>
{!isAndroid && (
<button
onClick={() => {
playPressSound();
setActiveView("main");
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="fixed bottom-8 left-8 z-50 h-10 flex items-center justify-center gap-2 px-4 text-xl mc-text-shadow outline-none border-none"
style={{
backgroundImage: isHovered
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
color: isHovered ? "#FFFF55" : "white",
}}
>
Back to Menu
</button>
)}
<motion.div
initial={{ y: "50%" }}
@@ -428,4 +497,4 @@ const CreditsView = memo(function CreditsView() {
);
});
export default CreditsView;
export default CreditsView;
+82 -85
View File
@@ -7,13 +7,13 @@ import {
useGame,
} from "../../context/LauncherContext";
import { usePluginActions } from "../../plugins/PluginContext";
import { usePlatform } from "../../hooks/usePlatform";
import type { Edition } from "../../types/edition";
const HomeView = memo(function HomeView() {
const { setActiveView, focusSection, onNavigateToSkin } =
useUI();
const { setActiveView, focusSection, onNavigateToSkin } = useUI();
const { profile, legacyMode } = useConfig();
const { playPressSound, playSfx } = useAudio();
const { playPressSound } = useAudio();
const {
handleLaunch,
editions,
@@ -25,6 +25,7 @@ const HomeView = memo(function HomeView() {
updatesAvailable,
} = useGame();
const pluginActions = usePluginActions("home-menu");
const { isAndroid } = usePlatform();
const isFocusedSection = focusSection === "menu";
const selectedEdition = editions.find((e: Edition) => e.id === profile);
@@ -35,79 +36,85 @@ const HomeView = memo(function HomeView() {
const hasAnyInstall = installs.length > 0;
const buttonsVal = useMemo(
() => {
const mainBtn = {
label: !hasAnyInstall
? "Install a version"
: isGameRunning
? "Stop Game"
: isDownloading
? "Installation in progress..."
: isInstalled
? "Play Game"
: `Download ${selectedVersionName}`,
action: !hasAnyInstall
? () => setActiveView("versions")
: isGameRunning
? stopGame
: isDownloading
? () => {}
: isInstalled
? handleLaunch
: () => toggleInstall(profile),
isDanger: isGameRunning,
disabled: isDownloading,
id: "main-action",
};
const buttonsVal = useMemo(() => {
const mainBtn = {
label: !hasAnyInstall
? "Install a version"
: isGameRunning
? "Stop Game"
: isDownloading
? "Installation in progress..."
: isInstalled
? "Play Game"
: `Download ${selectedVersionName}`,
action: !hasAnyInstall
? () => setActiveView("versions")
: isGameRunning
? stopGame
: isDownloading
? () => {}
: isInstalled
? handleLaunch
: () => toggleInstall(profile),
isDanger: isGameRunning,
disabled: isDownloading,
id: "main-action",
};
const pluginBtns = pluginActions.map((a) => ({
label: a.label,
action: () => a.onClick(),
const pluginBtns = pluginActions.map((a) => ({
label: a.label,
action: () => a.onClick(),
isDanger: false,
disabled: false,
id: a.id,
}));
const menuBtns = [
{
label: "Help & Options",
action: () => setActiveView("settings"),
isDanger: false,
disabled: false,
id: a.id,
}));
id: "settings",
},
{
label: "Versions",
action: () => setActiveView("versions"),
isDanger: false,
disabled: false,
id: "versions",
},
{
label: "Workshop",
action: () => setActiveView("workshop"),
isDanger: false,
disabled: false,
id: "workshop",
},
{
label: "Developer Tools",
action: () => setActiveView("devtools"),
isDanger: false,
disabled: false,
id: "devtools",
},
].filter((b) => !(isAndroid && b.id === "devtools"));
const menuBtns = [
{
label: "Help & Options",
action: () => setActiveView("settings"),
isDanger: false,
disabled: false,
id: "settings",
},
{
label: "Versions",
action: () => setActiveView("versions"),
isDanger: false,
disabled: false,
id: "versions",
},
{
label: "Workshop",
action: () => setActiveView("workshop"),
isDanger: false,
disabled: false,
id: "workshop",
},
{
label: "Developer Tools",
action: () => setActiveView("devtools"),
isDanger: false,
disabled: false,
id: "devtools",
},
];
return [mainBtn, ...pluginBtns, ...menuBtns];
},
[
isDownloading, hasAnyInstall, isInstalled, selectedVersionName,
handleLaunch, toggleInstall, profile, setActiveView,
isGameRunning, stopGame, pluginActions,
],
);
return [mainBtn, ...pluginBtns, ...menuBtns];
}, [
isDownloading,
hasAnyInstall,
isInstalled,
selectedVersionName,
handleLaunch,
toggleInstall,
profile,
setActiveView,
isGameRunning,
stopGame,
pluginActions,
isAndroid,
]);
useEffect(() => {
if (!isFocusedSection) {
@@ -125,7 +132,8 @@ const HomeView = memo(function HomeView() {
prev === null ? buttonsVal.length - 1 : prev > 0 ? prev - 1 : prev,
);
if (e.key === "ArrowLeft") onNavigateToSkin();
if (e.key === "Enter" && menuFocus !== null) buttonsVal[menuFocus].action();
if (e.key === "Enter" && menuFocus !== null)
buttonsVal[menuFocus].action();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
@@ -187,6 +195,7 @@ const HomeView = memo(function HomeView() {
{!legacyMode && (
<div className="pt-4 flex flex-col items-center w-full gap-3">
<div className="border-b-[3px] border-[#A0A0A0] w-48 opacity-60" />
<div className="flex gap-8">
<a
href="https://discord.gg/cQVKhQXcCx"
@@ -223,18 +232,6 @@ const HomeView = memo(function HomeView() {
/>
</a>
</div>
<div className="border-b-[3px] border-[#A0A0A0] w-48 opacity-60" />
<button
onClick={() => {
if (isFocusedSection) {
playSfx("orb.ogg");
setActiveView("credits");
}
}}
className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "pointer-events-none" : ""}`}
>
CREDITS
</button>
</div>
)}
</motion.div>
+30 -12
View File
@@ -7,6 +7,7 @@ import {
useGame,
} from "../../context/LauncherContext";
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
import { usePlatform } from "../../hooks/usePlatform";
import { lceOnlineService, SocialEntry } from "../../services/LceOnlineService";
import { TauriService } from "../../services/TauriService";
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
@@ -28,6 +29,7 @@ const LceOnlineView = memo(function LceOnlineView({
const { setActiveView, setIsUiHidden } = useUI();
const { animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio();
const { isAndroid } = usePlatform();
const game = useGame();
const [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn);
const opened = useRef(false);
@@ -80,25 +82,41 @@ const LceOnlineView = memo(function LceOnlineView({
if (!opened.current) {
opened.current = true;
new WebviewWindow('LCEOnline', {
url: "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher",
width: 400,
height: 570,
resizable: false,
title: 'Emerald Legacy Launcher - LCEOnline',
});
if (isAndroid) {
TauriService.startLceOnlineAuth()
.then((token) => {
lceOnlineService
.loginWithTokenAndFetchAccount(token)
.catch((e) => console.error(e));
setIsSignedIn(true);
})
.catch((e) => console.error("LCE Online auth failed", e));
} else {
new WebviewWindow('LCEOnline', {
url: "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher",
width: 400,
height: 570,
resizable: false,
title: 'Emerald Legacy Launcher - LCEOnline',
});
}
};
const unlisten = listen<string[]>('deep-link', async (event) => {
const authUrl = event.payload.find(u => u.startsWith('emerald://'));
if (!authUrl) return;
const token = new URL(authUrl).searchParams.get('token');
if (token) setIsSignedIn(true);
if (token) {
lceOnlineService
.loginWithTokenAndFetchAccount(token)
.catch((e) => console.error(e));
setIsSignedIn(true);
}
(await WebviewWindow.getByLabel('LCEOnline'))?.close();
});
return () => { unlisten.then(f => f()); };
}, [isSignedIn]);
}, [isSignedIn, isAndroid]);
useEffect(() => {
if (!addFriendTarget) return;
@@ -191,7 +209,7 @@ const LceOnlineView = memo(function LceOnlineView({
items.push({
id: `friend_${f.username}`,
type: "friend",
label: f.displayName,
label: f.displayName || f.username,
onClick: () => handleAction(() => lceOnlineService.removeFriend(f.username)),
onClickSecondary: isHosting
? () => handleAction(() => lceOnlineService.sendInvite(f.username))
@@ -203,7 +221,7 @@ const LceOnlineView = memo(function LceOnlineView({
items.push({
id: `req_in_${r.username}`,
type: "request_in",
label: r.displayName,
label: r.displayName || r.username,
onClick: () =>
handleAction(() => lceOnlineService.acceptFriendRequest(r.username)),
onClickSecondary: () =>
@@ -214,7 +232,7 @@ const LceOnlineView = memo(function LceOnlineView({
items.push({
id: `req_out_${r.username}`,
type: "request_out",
label: r.displayName,
label: r.displayName || r.username,
onClick: () =>
handleAction(() => lceOnlineService.declineFriendRequest(r.username)),
});
+34 -30
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { usePlatform } from "../../hooks/usePlatform";
import {
useUI,
useAudio,
@@ -15,6 +16,7 @@ import { ScreenshotImage } from "../common/ScreenshotImage";
const ScreenshotsView = memo(function ScreenshotsView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { isAndroid } = usePlatform();
const { editions } = useGame();
const { animationsEnabled } = useConfig();
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
@@ -239,30 +241,32 @@ const ScreenshotsView = memo(function ScreenshotsView() {
</div>
</div>
<div className="w-full mt-6 mb-4 flex justify-center">
<button
onClick={handleBack}
className={`
w-72 h-10 flex items-center justify-center text-xl mc-text-shadow border-none outline-none transition-all text-white
hover:text-[#FFFF55]
`}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/button_highlighted.png')";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/Button_Background.png')";
}}
>
Back
</button>
</div>
{!isAndroid && (
<div className="w-full mt-6 mb-4 flex justify-center">
<button
onClick={handleBack}
className={`
w-72 h-10 flex items-center justify-center text-xl mc-text-shadow border-none outline-none transition-all text-white
hover:text-[#FFFF55]
`}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/button_highlighted.png')";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/Button_Background.png')";
}}
>
Back
</button>
</div>
)}
<AnimatePresence>
{selectedScreenshot && (
@@ -270,7 +274,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] bg-black/90 flex flex-col items-center justify-center p-8 backdrop-blur-md"
className="fixed inset-0 z-[200] bg-black/90 flex flex-col items-center justify-center p-4 sm:p-8 backdrop-blur-md"
onClick={() => setSelectedScreenshot(null)}
>
<motion.div
@@ -278,7 +282,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="relative max-w-5xl w-full flex flex-col items-center border-2 border-[#555] rounded-sm p-2"
className="relative max-w-5xl w-full max-h-[92vh] overflow-y-auto flex flex-col items-center border-2 border-[#555] rounded-sm p-2"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
@@ -286,11 +290,11 @@ const ScreenshotsView = memo(function ScreenshotsView() {
}}
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full aspect-video bg-black/60 overflow-hidden border border-[#444] rounded-sm">
<div className="relative w-full aspect-video max-h-[55vh] bg-black/60 overflow-hidden border border-[#444] rounded-sm">
<ScreenshotImage
path={selectedScreenshot.path}
className="w-full h-full object-contain"
fallbackSrc="/images/Pack_Icon.png"
fallbackSrc="/images/Folder_Icon.png"
/>
<div className="absolute bottom-4 left-6 right-6 flex items-end justify-between pointer-events-none">
<div className="flex flex-col gap-1">
@@ -314,7 +318,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
</div>
</div>
<div className="flex gap-6 mt-6 mb-2 w-full justify-center px-6">
<div className="flex flex-wrap gap-4 sm:gap-6 mt-4 sm:mt-6 mb-2 w-full justify-center px-4 sm:px-6">
<button
onMouseEnter={() => setModalFocusIndex(0)}
onClick={() => handleOpenFolder(selectedScreenshot)}
@@ -391,7 +395,7 @@ const ScreenshotsView = memo(function ScreenshotsView() {
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="w-[420px] p-6 border-2 border-[#555] rounded-sm flex flex-col items-center"
className="w-[420px] max-w-[92vw] p-4 sm:p-6 border-2 border-[#555] rounded-sm flex flex-col items-center"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
+160 -76
View File
@@ -43,14 +43,14 @@ const SettingsView = memo(function SettingsView() {
setLaunchEnvVars,
skipIntro,
setSkipIntro,
profile,
androidRunner,
setAndroidRunner,
androidAudioBackend,
setAndroidAudioBackend,
} = useConfig();
const {
currentTrack,
skipTrack,
tracks,
playPressSound,
playBackSound,
} = useAudio();
const { currentTrack, skipTrack, tracks, playPressSound, playBackSound } =
useAudio();
const {
isGameRunning,
stopGame,
@@ -58,10 +58,10 @@ const SettingsView = memo(function SettingsView() {
runnerDownloadProgress,
downloadRunner,
} = useGame();
const { isLinux, isMac } = usePlatform();
const { isLinux, isMac, isAndroid } = usePlatform();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [currentSubMenu, setCurrentSubMenu] = useState<
"main" | "audio" | "video" | "launcher" | "game" | "plugins"
"main" | "audio" | "video" | "launcher" | "game" | "plugins" | "android"
>("main");
const [runners, setRunners] = useState<Runner[]>([]);
const [pluginsInfo, setPluginsInfo] = useState<PluginInfo[]>([]);
@@ -155,7 +155,7 @@ const SettingsView = memo(function SettingsView() {
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
<h3 class="text-2xl font-bold text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">Reset Setup</h3>
<h3 class="text-2xl text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">Reset Setup</h3>
<p class="text-[#333333] mb-8 text-left w-full px-4">Are you sure you want to reset launcher setup?</p>
<div class="flex flex-col gap-3 w-full px-4">
<button id="reset-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
@@ -193,7 +193,7 @@ const SettingsView = memo(function SettingsView() {
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
<h3 class="text-2xl font-bold text-[#333333] mb-2 text-left w-full px-4 mc-text-shadow">CONFIRM RESET</h3>
<h3 class="text-2xl text-[#333333] mb-2 text-left w-full px-4 mc-text-shadow">CONFIRM RESET</h3>
<div class="text-[#333333] mb-6 text-left w-full px-4">
<p class="mb-2">⚠️ This will:</p>
<ul class="list-none space-y-1 text-sm">
@@ -202,7 +202,7 @@ const SettingsView = memo(function SettingsView() {
<li>Show setup screen again</li>
<li>Require reconfiguration</li>
</ul>
<p class="mt-3 text-[#333333] font-bold">This action cannot be undone!</p>
<p class="mt-3 text-[#333333]">This action cannot be undone!</p>
</div>
<div class="flex flex-col gap-3 w-full px-4">
<button id="reset-final-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
@@ -328,6 +328,18 @@ const SettingsView = memo(function SettingsView() {
setFocusIndex(0);
},
});
if (isAndroid) {
items.push({
id: "android_menu",
label: "Android",
type: "button",
onClick: () => {
playPressSound();
setCurrentSubMenu("android");
setFocusIndex(0);
},
});
}
for (const action of pluginSettingsActions) {
items.push({
id: action.id,
@@ -339,6 +351,15 @@ const SettingsView = memo(function SettingsView() {
},
});
}
items.push({
id: "credits",
label: "Credits",
type: "button",
onClick: () => {
playPressSound();
setActiveView("credits");
},
});
} else if (currentSubMenu === "audio") {
items.push({
id: "music",
@@ -424,18 +445,20 @@ const SettingsView = memo(function SettingsView() {
},
});
} else if (currentSubMenu === "launcher") {
items.push({
id: "fullscreen",
label: `Start in Fullscreen: ${startFullscreen ? "ON" : "OFF"}`,
type: "button",
onClick: handleFullscreenToggle,
});
items.push({
id: "rpc",
label: `Discord RPC: ${rpcEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleRpcToggle,
});
if (!isAndroid) {
items.push({
id: "fullscreen",
label: `Start in Fullscreen: ${startFullscreen ? "ON" : "OFF"}`,
type: "button",
onClick: handleFullscreenToggle,
});
items.push({
id: "rpc",
label: `Discord RPC: ${rpcEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleRpcToggle,
});
}
items.push({
id: "skip_intro",
label: `Skip Intro: ${skipIntro ? "ON" : "OFF"}`,
@@ -448,7 +471,7 @@ const SettingsView = memo(function SettingsView() {
type: "button",
onClick: handleLegacyToggle,
});
if (isLinux) {
if (isLinux && !isAndroid) {
items.push({
id: "runner",
label: `Runner: ${selectedRunnerName}`,
@@ -479,33 +502,37 @@ const SettingsView = memo(function SettingsView() {
});
}
items.push({
id: "export_settings",
label: "Export Settings",
type: "button",
onClick: async () => {
playPressSound();
try {
await TauriService.exportSettings();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
},
});
items.push({
id: "import_settings",
label: "Import Settings",
type: "button",
onClick: async () => {
playPressSound();
try {
await TauriService.importSettings();
window.location.reload();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
},
});
if (!isAndroid) {
items.push({
id: "export_settings",
label: "Export Settings",
type: "button",
onClick: async () => {
playPressSound();
try {
await TauriService.exportSettings();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
},
});
}
if (!isAndroid) {
items.push({
id: "import_settings",
label: "Import Settings",
type: "button",
onClick: async () => {
playPressSound();
try {
await TauriService.importSettings();
window.location.reload();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
},
});
}
items.push({
id: "reset_setup",
label: "Reset Setup",
@@ -513,6 +540,58 @@ const SettingsView = memo(function SettingsView() {
onClick: handleResetSetup,
color: "orange",
});
} else if (currentSubMenu === "android") {
if (profile) {
items.push({
id: "android_container_settings",
label: "Container Settings",
type: "button",
onClick: () => {
playPressSound();
TauriService.openContainerSettings(profile).catch(console.error);
},
});
items.push({
id: "android_open_container",
label: "Open Container",
type: "button",
onClick: () => {
playPressSound();
TauriService.openInstanceFolder(profile).catch(console.error);
},
});
}
/*items.push({
id: "android_proton",
label: `Proton: ${androidRunner === "proton10" ? "Proton 10" : "Proton 11 (Default)"}`,
type: "button",
onClick: () => {
playPressSound();
const next = androidRunner === "proton10" ? "proton11" : "proton10";
setAndroidRunner(next);
TauriService.switchProton(next).catch(console.error);
},
});
items.push({
id: "android_install_driver",
label: "Install Latest Driver",
type: "button",
onClick: () => {
playPressSound();
TauriService.installLatestDriver().catch(console.error);
},
});*/
items.push({
id: "android_audio",
label: `Audio: ${androidAudioBackend === "alsa" ? "ALSA" : "PulseAudio (Default)"}`,
type: "button",
onClick: () => {
playPressSound();
const next = androidAudioBackend === "alsa" ? "pulseaudio" : "alsa";
setAndroidAudioBackend(next);
TauriService.setAudioBackend(next).catch(console.error);
},
});
}
if (isGameRunning) {
@@ -553,6 +632,7 @@ const SettingsView = memo(function SettingsView() {
animationsEnabled,
layout,
isLinux,
isAndroid,
mangohudEnabled,
selectedRunnerName,
isRunnerDownloading,
@@ -580,6 +660,9 @@ const SettingsView = memo(function SettingsView() {
launchPrefix,
launchEnvVars,
skipIntro,
profile,
androidRunner,
androidAudioBackend,
]);
useEffect(() => {
@@ -684,7 +767,7 @@ const SettingsView = memo(function SettingsView() {
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-5xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 whitespace-nowrap px-4">
{currentSubMenu === "main"
? "Settings"
: currentSubMenu === "audio"
@@ -918,30 +1001,31 @@ const SettingsView = memo(function SettingsView() {
</div>
)}
{(() => {
const backIndex = settingsItems.findIndex((i) => i.id === "back");
const backItem = settingsItems[backIndex];
if (!backItem || backItem.type !== "button") return null;
{!isAndroid &&
(() => {
const backIndex = settingsItems.findIndex((i) => i.id === "back");
const backItem = settingsItems[backIndex];
if (!backItem || backItem.type !== "button") return null;
return (
<button
data-index={backIndex}
onMouseEnter={() => setFocusIndex(backIndex)}
onClick={backItem.onClick}
className={`w-40 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#ffff00] mt-4 ${focusIndex === backIndex ? "text-[#ffff00]" : "text-white"}`}
style={{
backgroundImage:
focusIndex === backIndex
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Back
</button>
);
})()}
return (
<button
data-index={backIndex}
onMouseEnter={() => setFocusIndex(backIndex)}
onClick={backItem.onClick}
className={`w-40 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#ffff00] mt-4 ${focusIndex === backIndex ? "text-[#ffff00]" : "text-white"}`}
style={{
backgroundImage:
focusIndex === backIndex
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Back
</button>
);
})()}
{showModal === "args" && (
<motion.div
File diff suppressed because it is too large Load Diff
+90 -33
View File
@@ -10,6 +10,7 @@ import {
} from "../../context/LauncherContext";
import SkinViewer from "../common/SkinViewer";
import CapePreview from "../common/CapePreview";
import { usePlatform } from "../../hooks/usePlatform";
interface SavedSkin {
id: string;
@@ -55,16 +56,40 @@ const DEFAULT_SKINS: SavedSkin[] = [
isSlim: false,
},
{ id: "striker", name: "str1k3r", url: "/Skins/str1k3r.png", isSlim: true },
{
id: "bytebukkit",
name: "ByteBukkit",
url: "/Skins/byte.png",
isSlim: false,
},
{ id: "andipog", name: "Andi_Pog", url: "/Skins/andi.png", isSlim: false },
{ id: "sevenhundred", name: "700", url: "/Skins/700.png", isSlim: false },
{
id: "prismachunk0",
name: "PrismaChunk0",
name: "XeroChunks",
url: "/Skins/PrismaChunk0.png",
isSlim: false,
},
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true }, //neo: she's the best btw
{ id: "avalilac", name: "AvaLilac", url: "/Skins/ava.png", isSlim: true },
{ id: "huckle", name: "Huckle", url: "/Skins/huckle.png", isSlim: true },
{
id: "counterract",
name: "CounterrAct",
url: "/Skins/counterr.png",
isSlim: true,
},
{
id: "tranqlmao",
name: "Tranq",
url: "/Skins/tranq.png",
isSlim: true,
},
];
const DEFAULT_CAPES: SavedCape[] = [
{ id: "4j", name: "4J Studios", url: "/Capes/4J.png" },
{ id: "unused2", name: "Unused Cape 2", url: "/Capes/Unused_Cape_2.png" },
];
const HeadPreview = memo(function HeadPreview({ src }: { src: string }) {
@@ -101,7 +126,15 @@ const HeadPreview = memo(function HeadPreview({ src }: { src: string }) {
const SkinsView = memo(function SkinsView() {
const { setActiveView, setIsUiHidden } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin();
const { isAndroid } = usePlatform();
const {
skinUrl,
setSkinUrl,
skinIsSlim,
setSkinIsSlim,
capeUrl,
setCapeUrl,
} = useSkin();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"skin" | "cape">("skin");
@@ -121,13 +154,17 @@ const SkinsView = memo(function SkinsView() {
"lce-custom-capes",
[],
);
const savedCapes = [
...DEFAULT_CAPES,
...storedCapes.filter((c) => !DEFAULT_CAPES.some((d) => d.id === c.id)),
];
const [activeCapeId, setActiveCapeId] = useState<string | null>(null);
const TOP_BUTTONS_COUNT = viewMode === "skin" ? 3 : 3;
const SKINS_START_INDEX = TOP_BUTTONS_COUNT;
const BACK_BUTTON_INDEX =
SKINS_START_INDEX +
(viewMode === "skin" ? savedSkins.length : storedCapes.length);
(viewMode === "skin" ? savedSkins.length : savedCapes.length);
const ITEM_COUNT = BACK_BUTTON_INDEX + 1;
const setSavedSkins = (
@@ -229,10 +266,10 @@ const SkinsView = memo(function SkinsView() {
useEffect(() => {
if (!activeCapeId) {
const match = storedCapes.find((c) => c.url === capeUrl);
const match = savedCapes.find((c) => c.url === capeUrl);
if (match) setActiveCapeId(match.id);
}
}, [activeCapeId, storedCapes, capeUrl]);
}, [activeCapeId, savedCapes, capeUrl]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -319,7 +356,7 @@ const SkinsView = memo(function SkinsView() {
setFocusIndex(0);
} else if (focusIndex === BACK_BUTTON_INDEX) {
const itemCount =
viewMode === "cape" ? storedCapes.length + 1 : savedSkins.length;
viewMode === "cape" ? savedCapes.length + 1 : savedSkins.length;
setFocusIndex(SKINS_START_INDEX + itemCount - 1);
} else if (focusIndex >= SKINS_START_INDEX) {
const rowCount = 4;
@@ -337,7 +374,18 @@ const SkinsView = memo(function SkinsView() {
playPressSound();
setViewMode(viewMode === "skin" ? "cape" : "skin");
} else if (focusIndex < BACK_BUTTON_INDEX) {
handleSkinSelect(savedSkins[focusIndex - SKINS_START_INDEX]);
if (viewMode === "cape") {
const capeIdx = focusIndex - SKINS_START_INDEX;
if (capeIdx === 0) {
playPressSound();
setCapeUrl(null);
setActiveCapeId(null);
} else {
handleCapeSelect(savedCapes[capeIdx - 1]);
}
} else {
handleSkinSelect(savedSkins[focusIndex - SKINS_START_INDEX]);
}
} else {
playBackSound();
setActiveView("main");
@@ -349,7 +397,7 @@ const SkinsView = memo(function SkinsView() {
}, [
focusIndex,
savedSkins.length,
storedCapes.length,
savedCapes.length,
playBackSound,
setActiveView,
playPressSound,
@@ -400,6 +448,9 @@ const SkinsView = memo(function SkinsView() {
const isDefaultSkin = (id: string | null) =>
DEFAULT_SKINS.some((d) => d.id === id);
const isDefaultCape = (id: string | null) =>
DEFAULT_CAPES.some((d) => d.id === id);
const handleDeleteActive = () => {
if (!activeSkinId || isDefaultSkin(activeSkinId)) return;
playPressSound();
@@ -443,7 +494,7 @@ const SkinsView = memo(function SkinsView() {
};
const handleDeleteActiveCape = () => {
if (!activeCapeId) return;
if (!activeCapeId || isDefaultCape(activeCapeId)) return;
playPressSound();
const updatedCapes = storedCapes.filter((c) => c.id !== activeCapeId);
setStoredCapes(updatedCapes);
@@ -462,6 +513,8 @@ const SkinsView = memo(function SkinsView() {
isDefaultSkin(activeSkinId) ||
(!activeSkinId && skinUrl === "/images/Default.png");
const isActiveCapeDefault = !activeCapeId && !capeUrl;
const isCapeDeleteDisabled =
isActiveCapeDefault || isDefaultCape(activeCapeId);
return (
<motion.div
@@ -504,7 +557,7 @@ const SkinsView = memo(function SkinsView() {
data-index="1"
onMouseEnter={() => {
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
else if (viewMode === "cape" && !isActiveCapeDefault)
else if (viewMode === "cape" && !isCapeDeleteDisabled)
setFocusIndex(1);
}}
onClick={() => {
@@ -514,7 +567,7 @@ const SkinsView = memo(function SkinsView() {
}}
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)
(viewMode === "cape" && isCapeDeleteDisabled)
? "text-gray-400 opacity-80 cursor-not-allowed"
: focusIndex === 1
? "text-[#FFFF55]"
@@ -523,7 +576,7 @@ const SkinsView = memo(function SkinsView() {
style={{
backgroundImage:
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isActiveCapeDefault)
(viewMode === "cape" && isCapeDeleteDisabled)
? "url('/images/Button_Background2.png')"
: focusIndex === 1
? "url('/images/button_highlighted.png')"
@@ -659,7 +712,7 @@ const SkinsView = memo(function SkinsView() {
No Cape
</span>
</div>
{storedCapes.map((cape, i) => {
{savedCapes.map((cape, i) => {
const idx = SKINS_START_INDEX + 1 + i;
const isActive = activeCapeId
? activeCapeId === cape.id
@@ -693,9 +746,10 @@ const SkinsView = memo(function SkinsView() {
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 relative z-10 ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"}`}
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors relative z-10 ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
readOnly={isDefaultCape(cape.id)}
/>
</div>
);
@@ -719,28 +773,31 @@ const SkinsView = memo(function SkinsView() {
onNavigateRight={() => {}}
hideControls
style={{ top: "45%" }}
slim={skinIsSlim}
/>
</div>
<button
data-index={BACK_BUTTON_INDEX}
onMouseEnter={() => setFocusIndex(BACK_BUTTON_INDEX)}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className={`w-72 h-14 shrink-0 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>
{!isAndroid && (
<button
data-index={BACK_BUTTON_INDEX}
onMouseEnter={() => setFocusIndex(BACK_BUTTON_INDEX)}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className={`w-72 h-14 shrink-0 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 && viewMode === "skin" && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
+434 -241
View File
@@ -7,6 +7,8 @@ import ImportWorldModal from "../modals/ImportWorldModal";
import PlaytimeModal from "../modals/PlaytimeModal";
import CustomizeModal from "../modals/CustomizeModal";
import DownloadDlcModal from "../modals/DownloadDlcModal";
import OptionsModal from "../modals/OptionsModal";
import { parseSchema } from "../../utils/argsSchema";
import {
useUI,
useConfig,
@@ -14,7 +16,9 @@ import {
useGame,
} from "../../context/LauncherContext";
import { ScreenshotImage } from "../common/ScreenshotImage";
import { usePlatform } from "../../hooks/usePlatform";
import type { Edition } from "../../types/edition";
import { HIDDEN_INSTANCE_URL } from "../../types/edition";
interface DeleteConfirmButtonProps {
label: string;
onClick: () => void;
@@ -63,6 +67,8 @@ const VersionsView = memo(function VersionsView() {
profile: selectedProfile,
setProfile: setSelectedProfile,
animationsEnabled,
instanceLaunchArgs,
setInstanceLaunchArgs,
} = useConfig();
const { playPressSound, playBackSound } = useAudio();
const {
@@ -84,6 +90,12 @@ const VersionsView = memo(function VersionsView() {
saveCustomPath,
} = useGame();
const { isDayTime } = useConfig();
const { isAndroid } = usePlatform();
const visibleEditions = editions.filter(
(e) =>
e.url !== HIDDEN_INSTANCE_URL ||
installedVersions.includes(e.instanceId),
);
const [focusIndex, setFocusIndex] = useState<number>(0);
const [focusBtn, setFocusBtn] = useState<number>(0);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
@@ -91,27 +103,56 @@ const VersionsView = memo(function VersionsView() {
const [setUidTargetId, setSetUidTargetId] = useState("");
const [editingEdition, setEditingEdition] = useState<Edition | null>(null);
const [isImportWorldModalOpen, setIsImportWorldModalOpen] = useState(false);
const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string } | null>(null);
const [importWorldTarget, setImportWorldTarget] = useState<{
id: string;
name: string;
} | null>(null);
const [isPlaytimeModalOpen, setIsPlaytimeModalOpen] = useState(false);
const [playtimeTarget, setPlaytimeTarget] = useState<{ id: string; name: string } | null>(null);
const [playtimeTarget, setPlaytimeTarget] = useState<{
id: string;
name: string;
} | null>(null);
const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false);
const [customizeTarget, setCustomizeTarget] = useState<Edition | null>(null);
const [playtimeMap, setPlaytimeMap] = useState<Record<string, PlaytimeResponse>>({});
const [playtimeMap, setPlaytimeMap] = useState<
Record<string, PlaytimeResponse>
>({});
const [initialPath, setInitialPath] = useState<string>("");
const [hoveredBtn, setHoveredBtn] = useState<{
row: number;
btn: string;
} | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [deleteConfirmEdition, setDeleteConfirmEdition] = useState<Edition | null>(null);
const [deleteConfirmEdition, setDeleteConfirmEdition] =
useState<Edition | null>(null);
const [isDlcModalOpen, setIsDlcModalOpen] = useState(false);
const [dlcTargetEdition, setDlcTargetEdition] = useState<Edition | null>(null);
const [dlcTargetEdition, setDlcTargetEdition] = useState<Edition | null>(
null,
);
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [optionsTarget, setOptionsTarget] = useState<{
id: string;
name: string;
} | null>(null);
const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const ITEM_COUNT = editions.length + 3;
const ITEM_COUNT = visibleEditions.length + 3;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (
isImportModalOpen ||
isSetUidModalOpen ||
isImportWorldModalOpen ||
isPlaytimeModalOpen ||
isCustomizeModalOpen ||
isDlcModalOpen ||
isOptionsModalOpen ||
deleteConfirmEdition
) {
return;
}
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
@@ -129,8 +170,8 @@ const VersionsView = memo(function VersionsView() {
setFocusBtn(0);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
if (focusIndex < editions.length) {
const edition = editions[focusIndex];
if (focusIndex < visibleEditions.length) {
const edition = visibleEditions[focusIndex];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
@@ -138,8 +179,8 @@ const VersionsView = memo(function VersionsView() {
}
} else if (e.key === "ArrowRight") {
e.preventDefault();
if (focusIndex < editions.length) {
const edition = editions[focusIndex];
if (focusIndex < visibleEditions.length) {
const edition = visibleEditions[focusIndex];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1;
@@ -147,8 +188,8 @@ const VersionsView = memo(function VersionsView() {
}
} else if (e.key === "Enter") {
e.preventDefault();
if (focusIndex < editions.length) {
const edition = editions[focusIndex];
if (focusIndex < visibleEditions.length) {
const edition = visibleEditions[focusIndex];
const isInstalled = installedVersions.includes(edition.instanceId);
const isDownloading = downloadingIds.includes(edition.instanceId);
if (focusBtn === 0) {
@@ -170,10 +211,10 @@ const VersionsView = memo(function VersionsView() {
playPressSound();
cycleBranch(edition.id);
}
} else if (focusIndex === editions.length) {
} else if (focusIndex === visibleEditions.length) {
playPressSound();
setIsImportModalOpen(true);
} else if (focusIndex === editions.length + 1) {
} else if (focusIndex === visibleEditions.length + 1) {
playPressSound();
handleImportFolder();
} else {
@@ -200,10 +241,18 @@ const VersionsView = memo(function VersionsView() {
handleCancelDownload,
addToSteam,
isDayTime,
isImportModalOpen,
isSetUidModalOpen,
isImportWorldModalOpen,
isPlaytimeModalOpen,
isCustomizeModalOpen,
isDlcModalOpen,
isOptionsModalOpen,
deleteConfirmEdition,
]);
useEffect(() => {
if (focusIndex < editions.length && listRef.current) {
if (focusIndex < visibleEditions.length && listRef.current) {
const el = listRef.current.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
@@ -216,18 +265,43 @@ const VersionsView = memo(function VersionsView() {
useEffect(() => {
const fetchPlaytimes = async () => {
const map: Record<string, PlaytimeResponse> = {};
await Promise.all(installedVersions.map(async (id) => {
try {
map[id] = await TauriService.getPlaytime(id);
} catch (e) {
console.error(e);
}
}));
await Promise.all(
installedVersions.map(async (id) => {
try {
map[id] = await TauriService.getPlaytime(id);
} catch (e) {
console.error(e);
}
}),
);
setPlaytimeMap(map);
};
fetchPlaytimes();
}, [installedVersions]);
useEffect(() => {
let cancelled = false;
const checkSchemas = async () => {
const map: Record<string, boolean> = {};
await Promise.all(
installedVersions.map(async (id) => {
try {
const schema = await TauriService.getInstanceArgsSchema(id);
map[id] = !!schema && parseSchema(schema) !== null;
} catch (e) {
console.error(e);
map[id] = false;
}
}),
);
if (!cancelled) setArgsSchemas(map);
};
checkSchemas();
return () => {
cancelled = true;
};
}, [installedVersions]);
const handleEditionClick = (edition: Edition, index: number) => {
const isInstalled = installedVersions.includes(edition.instanceId);
if (isInstalled) {
@@ -250,7 +324,7 @@ const VersionsView = memo(function VersionsView() {
};
const handleImportWorld = (instanceId: string) => {
const edition = editions.find((e: Edition) => e.instanceId === instanceId);
const edition = visibleEditions.find((e: Edition) => e.instanceId === instanceId);
setImportWorldTarget({ id: instanceId, name: edition?.name ?? instanceId });
setIsImportWorldModalOpen(true);
};
@@ -268,15 +342,13 @@ const VersionsView = memo(function VersionsView() {
Versions
</h2>
<div
className="w-full min-w-[480px] p-6 mb-4 mc-options-bg"
>
<div className="w-full min-w-[480px] p-6 mb-4 mc-options-bg">
<div
ref={listRef}
className="w-full max-h-[45vh] overflow-y-auto py-2 custom-scrollbar"
>
<div className="flex flex-col gap-1">
{editions.map((edition: Edition, i: number) => {
{visibleEditions.map((edition: Edition, i: number) => {
const isInstalled = installedVersions.includes(
edition.instanceId,
);
@@ -350,7 +422,10 @@ const VersionsView = memo(function VersionsView() {
onClick={(e) => {
e.stopPropagation();
playPressSound();
setPlaytimeTarget({ id: edition.instanceId, name: edition.name });
setPlaytimeTarget({
id: edition.instanceId,
name: edition.name,
});
setIsPlaytimeModalOpen(true);
}}
className="flex items-center gap-1.5 px-2 py-1 bg-black/60 border border-[#555] hover:border-[#FFFF55] group transition-colors flex-shrink-0"
@@ -371,7 +446,11 @@ const VersionsView = memo(function VersionsView() {
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="text-xs text-[#AAAAAA] group-hover:text-[#FFFF55] leading-none transition-colors">
{playtimeMap[edition.instanceId] ? formatPlaytime(playtimeMap[edition.instanceId].totalSeconds) : ""}
{playtimeMap[edition.instanceId]
? formatPlaytime(
playtimeMap[edition.instanceId].totalSeconds,
)
: ""}
</span>
</button>
)}
@@ -510,7 +589,7 @@ const VersionsView = memo(function VersionsView() {
Update Available!
</button>
)}
{!isInstalled && (
{!isAndroid && !isInstalled && (
<button
onClick={async (e) => {
e.stopPropagation();
@@ -519,9 +598,11 @@ const VersionsView = memo(function VersionsView() {
try {
const folder = await TauriService.pickFolder();
if (folder) {
const entries = await TauriService.listDirectory(folder);
const entries =
await TauriService.listDirectory(folder);
if (entries.length > 0) {
const dialog = document.createElement("div");
const dialog =
document.createElement("div");
dialog.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
@@ -534,14 +615,20 @@ const VersionsView = memo(function VersionsView() {
</div>
`;
document.body.appendChild(dialog);
const close = () => document.body.removeChild(dialog);
dialog.querySelector("#empty-dir-ok")?.addEventListener("click", close);
const close = () =>
document.body.removeChild(dialog);
dialog
.querySelector("#empty-dir-ok")
?.addEventListener("click", close);
dialog.addEventListener("click", (e) => {
if (e.target === dialog) close();
});
return;
}
await saveCustomPath(edition.instanceId, folder);
await saveCustomPath(
edition.instanceId,
folder,
);
toggleInstall(edition.instanceId);
}
} catch (err) {
@@ -586,6 +673,42 @@ const VersionsView = memo(function VersionsView() {
Download DLC
</button>
) : null}
{argsSchemas[edition.instanceId] && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setOptionsTarget({
id: edition.instanceId,
name: edition.name,
});
setIsOptionsModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
Options
</button>
)}
{Array.isArray(edition.branches) &&
edition.branches.length > 0 && (
<button
@@ -605,157 +728,173 @@ const VersionsView = memo(function VersionsView() {
</button>
)}
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.openInstanceFolder(edition.instanceId);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<img
src="/images/Folder_Icon.png"
alt=""
className="w-3.5 h-3.5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
Open Folder
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
const PANORAMA_PROFILES = [
"legacy_evolved",
"vanilla_tu19",
"360revived",
"vanilla_tu24",
];
const panoId = PANORAMA_PROFILES.includes(
edition.id,
)
? edition.id
: "vanilla_tu19";
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`;
addToSteam(
edition.instanceId,
edition.name,
edition.titleImage ?? "",
panoramaUrl,
);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<img
src="/images/steam.png"
alt=""
className="w-3.5 h-3.5 object-contain invert brightness-0"
style={{ imageRendering: "pixelated" }}
/>
Add to Steam
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
handleImportWorld(edition.instanceId);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
{!isAndroid && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.openInstanceFolder(
edition.instanceId,
);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Import World
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.backupInstance(edition.instanceId).catch((err) => {
if (err !== "CANCELED") console.error(err);
});
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
<img
src="/images/Folder_Icon.png"
alt=""
className="w-3.5 h-3.5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
Open Folder
</button>
)}
{!isAndroid && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
const PANORAMA_PROFILES = [
"legacy_evolved",
"vanilla_tu19",
"360revived",
"vanilla_tu24",
];
const panoId = PANORAMA_PROFILES.includes(
edition.id,
)
? edition.id
: "vanilla_tu19";
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`;
addToSteam(
edition.instanceId,
edition.name,
edition.titleImage ?? "",
panoramaUrl,
);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
Backup
</button>
<button
onClick={async (e) => {
e.stopPropagation();
playPressSound();
setOpenMenuId(null);
try {
await TauriService.restoreInstance();
} catch (err) {
if (err !== "CANCELED") console.error(err);
}
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
<img
src="/images/steam.png"
alt=""
className="w-3.5 h-3.5 object-contain invert brightness-0"
style={{ imageRendering: "pixelated" }}
/>
Add to Steam
</button>
)}
{!isAndroid && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
handleImportWorld(edition.instanceId);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<path d="M21 12a9 9 0 1 1-9-9 9 9 0 0 1 9 9z" />
<polyline points="12 7 12 12 15 15" />
</svg>
Restore
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setCustomizeTarget(edition);
setIsCustomizeModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Import World
</button>
)}
{!isAndroid && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
TauriService.backupInstance(
edition.instanceId,
).catch((err) => {
if (err !== "CANCELED") console.error(err);
});
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Customize
</button>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
Backup
</button>
)}
{!isAndroid && (
<button
onClick={async (e) => {
e.stopPropagation();
playPressSound();
setOpenMenuId(null);
try {
await TauriService.restoreInstance();
} catch (err) {
if (err !== "CANCELED") console.error(err);
}
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M21 12a9 9 0 1 1-9-9 9 9 0 0 1 9 9z" />
<polyline points="12 7 12 12 15 15" />
</svg>
Restore
</button>
)}
{!isAndroid && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setCustomizeTarget(edition);
setIsCustomizeModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Customize
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -841,14 +980,14 @@ const VersionsView = memo(function VersionsView() {
setInitialPath("");
setIsImportModalOpen(true);
}}
onMouseEnter={() => setFocusIndex(editions.length)}
onMouseEnter={() => setFocusIndex(visibleEditions.length)}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage:
(hoveredBtn?.row === editions.length &&
(hoveredBtn?.row === visibleEditions.length &&
hoveredBtn?.btn === "add") ||
focusIndex === editions.length
focusIndex === visibleEditions.length
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
@@ -868,59 +1007,63 @@ const VersionsView = memo(function VersionsView() {
</svg>
</button>
<button
onClick={() => {
playPressSound();
handleImportFolder();
}}
onMouseEnter={() => setFocusIndex(editions.length + 1)}
onMouseLeave={() => setHoveredBtn(null)}
title="Import Custom TU"
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage:
(hoveredBtn?.row === editions.length &&
hoveredBtn?.btn === "folder_import") ||
focusIndex === editions.length + 1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/Folder_Icon.png"
alt="Import Custom TU"
className="w-5 h-5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
{!isAndroid && (
<button
onClick={() => {
playPressSound();
handleImportFolder();
}}
onMouseEnter={() => setFocusIndex(visibleEditions.length + 1)}
onMouseLeave={() => setHoveredBtn(null)}
title="Import Custom TU"
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage:
(hoveredBtn?.row === visibleEditions.length &&
hoveredBtn?.btn === "folder_import") ||
focusIndex === visibleEditions.length + 1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/Folder_Icon.png"
alt="Import Custom TU"
className="w-5 h-5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
)}
</div>
</div>
</div>
</div>
<div className="flex justify-center">
<button
data-index={editions.length + 2}
onMouseEnter={() => setFocusIndex(editions.length + 2)}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white"
style={{
backgroundImage:
focusIndex === editions.length + 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Done
</button>
</div>
{!isAndroid && (
<div className="flex justify-center">
<button
data-index={visibleEditions.length + 2}
onMouseEnter={() => setFocusIndex(visibleEditions.length + 2)}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white"
style={{
backgroundImage:
focusIndex === visibleEditions.length + 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Done
</button>
</div>
)}
<CustomTUModal
isOpen={isImportModalOpen}
@@ -929,7 +1072,12 @@ const VersionsView = memo(function VersionsView() {
setEditingEdition(null);
setInitialPath("");
}}
onImport={(ed: { name: string; desc: string; url: string; path?: string }) => {
onImport={(ed: {
name: string;
desc: string;
url: string;
path?: string;
}) => {
if (editingEdition) {
onUpdateEdition(editingEdition.id, ed);
} else {
@@ -948,14 +1096,17 @@ const VersionsView = memo(function VersionsView() {
onClose={() => setIsSetUidModalOpen(false)}
playPressSound={playPressSound}
playBackSound={playBackSound}
instances={editions}
instances={visibleEditions}
installedVersions={installedVersions}
targetInstanceId={setUidTargetId}
/>
<ImportWorldModal
isOpen={isImportWorldModalOpen}
onClose={() => { setIsImportWorldModalOpen(false); setImportWorldTarget(null); }}
onClose={() => {
setIsImportWorldModalOpen(false);
setImportWorldTarget(null);
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
targetInstanceId={importWorldTarget?.id ?? ""}
@@ -964,7 +1115,10 @@ const VersionsView = memo(function VersionsView() {
<PlaytimeModal
isOpen={isPlaytimeModalOpen}
onClose={() => { setIsPlaytimeModalOpen(false); setPlaytimeTarget(null); }}
onClose={() => {
setIsPlaytimeModalOpen(false);
setPlaytimeTarget(null);
}}
playBackSound={playBackSound}
instanceId={playtimeTarget?.id ?? ""}
instanceName={playtimeTarget?.name ?? ""}
@@ -972,12 +1126,25 @@ const VersionsView = memo(function VersionsView() {
<CustomizeModal
isOpen={isCustomizeModalOpen}
onClose={() => { setIsCustomizeModalOpen(false); setCustomizeTarget(null); }}
onClose={() => {
setIsCustomizeModalOpen(false);
setCustomizeTarget(null);
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
editionName={customizeTarget?.name ?? ""}
currentTitleImage={customizeTarget ? customizations[customizeTarget.instanceId]?.titleImage || customizeTarget.titleImage : undefined}
currentPanorama={customizeTarget ? customizations[customizeTarget.instanceId]?.panorama || customizeTarget.panorama : undefined}
currentTitleImage={
customizeTarget
? customizations[customizeTarget.instanceId]?.titleImage ||
customizeTarget.titleImage
: undefined
}
currentPanorama={
customizeTarget
? customizations[customizeTarget.instanceId]?.panorama ||
customizeTarget.panorama
: undefined
}
onSave={(updates) => {
if (customizeTarget) {
updateCustomization(customizeTarget.instanceId, updates);
@@ -987,7 +1154,10 @@ const VersionsView = memo(function VersionsView() {
<DownloadDlcModal
isOpen={isDlcModalOpen}
onClose={() => { setIsDlcModalOpen(false); setDlcTargetEdition(null); }}
onClose={() => {
setIsDlcModalOpen(false);
setDlcTargetEdition(null);
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
editionName={dlcTargetEdition?.name ?? ""}
@@ -995,6 +1165,29 @@ const VersionsView = memo(function VersionsView() {
officialDLC={dlcTargetEdition?.officialDLC ?? ""}
/>
<OptionsModal
isOpen={isOptionsModalOpen}
onClose={() => {
setIsOptionsModalOpen(false);
setOptionsTarget(null);
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
instanceId={optionsTarget?.id ?? ""}
instanceName={optionsTarget?.name ?? ""}
savedValues={
optionsTarget
? instanceLaunchArgs[optionsTarget.id]?.values
: undefined
}
onSave={(instanceId, values, args) => {
setInstanceLaunchArgs((prev) => ({
...prev,
[instanceId]: { values, args },
}));
}}
/>
{deleteConfirmEdition && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div
+35 -31
View File
@@ -25,6 +25,7 @@ import {
type CustomEdition,
} from "../../services/TauriService";
import { PluginManager } from "../../plugins/PluginManager";
import { usePlatform } from "../../hooks/usePlatform";
import { BASE_EDITIONS } from "../../hooks/useGameManager";
const REGISTRY_URL =
"https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/registry.json";
@@ -183,6 +184,7 @@ const WorkshopView = memo(function WorkshopView({
}: WorkshopViewProps) {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { isAndroid } = usePlatform();
const config = useConfig();
const containerRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
@@ -722,10 +724,6 @@ const WorkshopView = memo(function WorkshopView({
transition={{ duration: config.animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full h-full max-h-full relative font-['Mojangles'] text-white select-none outline-none focus:outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-4 mb-6 border-b-2 border-[#373737] pb-2 w-[30%] max-w-[250px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
Workshop
</h2>
<div className="flex items-center justify-center gap-0 mb-4 w-full px-4">
<div
className="flex items-center gap-1 px-[11px] py-1 rounded-sm bg-[#696969] border-2 border-black"
@@ -1109,30 +1107,32 @@ const WorkshopView = memo(function WorkshopView({
)}
</div>
<div className="w-full mt-6 mb-4 flex justify-center">
<button
onClick={() => {
playBackSound();
setActiveView("main");
}}
className="w-72 h-10 flex items-center justify-center text-xl mc-text-shadow hover:text-[#FFFF55] text-white border-none outline-none transition-all"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/button_highlighted.png')";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/Button_Background.png')";
}}
>
Back
</button>
</div>
{!isAndroid && (
<div className="w-full mt-6 mb-4 flex justify-center">
<button
onClick={() => {
playBackSound();
setActiveView("main");
}}
className="w-72 h-10 flex items-center justify-center text-xl mc-text-shadow hover:text-[#FFFF55] text-white border-none outline-none transition-all"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/button_highlighted.png')";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundImage =
"url('/images/Button_Background.png')";
}}
>
Back
</button>
</div>
)}
<AnimatePresence>
{selectedPkg && (
@@ -2035,9 +2035,11 @@ function InstallModal({
const targets: { id: string; zips: number }[] = [];
for (const depId of dependencies) {
const dep = allPackages.find((p) => p.id === depId);
if (dep?.zips) targets.push({ id: dep.id, zips: Object.keys(dep.zips).length });
if (dep?.zips)
targets.push({ id: dep.id, zips: Object.keys(dep.zips).length });
}
if (pkg.zips) targets.push({ id: pkg.id, zips: Object.keys(pkg.zips).length });
if (pkg.zips)
targets.push({ id: pkg.id, zips: Object.keys(pkg.zips).length });
return targets;
}, [dependencies, allPackages, pkg.zips, pkg.id]);
@@ -2262,7 +2264,9 @@ function InstallModal({
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-[#FFFF55] mc-text-shadow truncate">
{progressLabel ||
(isPluginTab ? "Downloading plugin files" : "Downloading assets")}
(isPluginTab
? "Downloading plugin files"
: "Downloading assets")}
</span>
<span className="text-[11px] text-[#FFFF55] mc-text-shadow shrink-0">
{Math.floor(isPluginTab ? progress : overallProgress)}%
+198 -60
View File
@@ -1,4 +1,11 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from "react";
import React, {
createContext,
useContext,
useState,
useEffect,
useMemo,
useCallback,
} from "react";
import { useAppConfig } from "../hooks/useAppConfig";
import { TauriService } from "../services/TauriService";
import { useAudioController } from "../hooks/useAudioController";
@@ -7,6 +14,7 @@ import { useSkinSync } from "../hooks/useSkinSync";
import { useDiscordRPC } from "../hooks/useDiscordRPC";
import { useGamepad } from "../hooks/useGamepad";
import { useUpdateCheck } from "../hooks/useUpdateCheck";
import { SPLASHES } from "../data/splashes";
import RpcService from "../services/RpcService";
interface UIContextType {
@@ -29,10 +37,18 @@ interface UIContextType {
clearUpdateMessage: () => void;
}
const UIContext = createContext<UIContextType | undefined>(undefined);
export const ConfigContext = createContext<ReturnType<typeof useAppConfig> | undefined>(undefined);
export const AudioContext = createContext<ReturnType<typeof useAudioController> | undefined>(undefined);
export const GameContext = createContext<ReturnType<typeof useGameManager> | undefined>(undefined);
export const SkinContext = createContext<ReturnType<typeof useSkinSync> | undefined>(undefined);
export const ConfigContext = createContext<
ReturnType<typeof useAppConfig> | undefined
>(undefined);
export const AudioContext = createContext<
ReturnType<typeof useAudioController> | undefined
>(undefined);
export const GameContext = createContext<
ReturnType<typeof useGameManager> | undefined
>(undefined);
export const SkinContext = createContext<
ReturnType<typeof useSkinSync> | undefined
>(undefined);
export function LauncherProvider({ children }: { children: React.ReactNode }) {
const [showIntro, setShowIntro] = useState(true);
const [logoAnimDone, setLogoAnimDone] = useState(false);
@@ -55,7 +71,11 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setCustomizations: configRaw.setCustomizations,
extraLaunchArgs: configRaw.extraLaunchArgs,
});
const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions });
const skinSync = useSkinSync({
username: configRaw.username,
profile: configRaw.profile,
editions: gameRaw.editions,
});
const audioRaw = useAudioController({
musicVol: configRaw.musicVol,
sfxVol: configRaw.sfxVol,
@@ -63,33 +83,80 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
isWindowVisible,
});
const config = useMemo(() => configRaw, [
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
configRaw.customPaths,
configRaw.customizations,
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen,
configRaw.skipIntro,
]);
const config = useMemo(
() => configRaw,
[
configRaw.username,
configRaw.theme,
configRaw.layout,
configRaw.vfxEnabled,
configRaw.rpcEnabled,
configRaw.musicVol,
configRaw.sfxVol,
configRaw.isDayTime,
configRaw.profile,
configRaw.linuxRunner,
configRaw.perfBoost,
configRaw.customEditions,
configRaw.customPaths,
configRaw.customizations,
configRaw.legacyMode,
configRaw.animationsEnabled,
configRaw.mangohudEnabled,
configRaw.extraLaunchArgs,
configRaw.launchPrefix,
configRaw.launchEnvVars,
configRaw.startFullscreen,
configRaw.skipIntro,
configRaw.instanceLaunchArgs,
configRaw.androidRunner,
configRaw.androidAudioBackend,
],
);
const game = useMemo(() => gameRaw, [
gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
gameRaw.downloadingIds, gameRaw.editions, gameRaw.isRunnerDownloading,
gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition,
gameRaw.handleUninstall, gameRaw.handleCancelDownload, gameRaw.gameUpdateMessage, configRaw.profile,
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition, gameRaw.downloadRunner,
gameRaw.customizations, gameRaw.updateCustomization,
gameRaw.gameLog, gameRaw.clearGameLog,
]);
const game = useMemo(
() => gameRaw,
[
gameRaw.installs,
gameRaw.isGameRunning,
gameRaw.downloadProgress,
gameRaw.downloadingIds,
gameRaw.editions,
gameRaw.isRunnerDownloading,
gameRaw.runnerDownloadProgress,
gameRaw.error,
gameRaw.updateCustomEdition,
gameRaw.handleUninstall,
gameRaw.handleCancelDownload,
gameRaw.gameUpdateMessage,
configRaw.profile,
gameRaw.updatesAvailable,
gameRaw.addToSteam,
gameRaw.steamSuccessMessage,
gameRaw.cycleBranch,
gameRaw.toggleInstall,
gameRaw.checkInstalls,
gameRaw.handleLaunch,
gameRaw.stopGame,
gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition,
gameRaw.downloadRunner,
gameRaw.customizations,
gameRaw.updateCustomization,
gameRaw.gameLog,
gameRaw.clearGameLog,
],
);
const audio = useMemo(() => audioRaw, [
audioRaw.currentTrack, audioRaw.splashIndex, audioRaw.tracks, audioRaw.splashes
]);
const audio = useMemo(
() => audioRaw,
[
audioRaw.currentTrack,
audioRaw.splashIndex,
audioRaw.tracks,
audioRaw.splashes,
],
);
useDiscordRPC({
rpcEnabled: config.rpcEnabled,
@@ -111,7 +178,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (activeView === "main") {
audioRaw.setSplashIndex(-1);
audioRaw.setSplashIndex(Math.floor(Math.random() * SPLASHES.length));
}
}, [activeView]);
@@ -143,31 +210,50 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
extraLaunchArgs: config.extraLaunchArgs,
launchPrefix: config.launchPrefix,
launchEnvVars: config.launchEnvVars,
startFullscreen: config.startFullscreen,
skipIntro: config.skipIntro,
startFullscreen: config.startFullscreen,
skipIntro: config.skipIntro,
instanceLaunchArgs: config.instanceLaunchArgs,
}).catch(console.error);
}
}, [
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
config.perfBoost, config.customEditions, config.profile,
config.username,
skinSync.skinBase64,
config.theme,
config.linuxRunner,
config.perfBoost,
config.customEditions,
config.profile,
config.customPaths,
config.customizations, config.vfxEnabled, config.animationsEnabled,
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
config.launchEnvVars, config.isLoaded, config.startFullscreen,
config.customizations,
config.vfxEnabled,
config.animationsEnabled,
config.rpcEnabled,
config.musicVol,
config.sfxVol,
config.legacyMode,
config.mangohudEnabled,
config.extraLaunchArgs,
config.launchPrefix,
config.launchEnvVars,
config.isLoaded,
config.startFullscreen,
config.skipIntro,
config.instanceLaunchArgs,
]);
useEffect(() => {
const setupVisibilityDetection = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
const unlistenClose = await listen("tauri://close-requested", async () => {
setIsWindowVisible(false);
if (config.rpcEnabled) {
await RpcService.StopRPC();
}
});
const unlistenClose = await listen(
"tauri://close-requested",
async () => {
setIsWindowVisible(false);
if (config.rpcEnabled) {
await RpcService.StopRPC();
}
},
);
const unlistenShow = await listen("tauri://window-shown", () => {
setIsWindowVisible(true);
@@ -178,14 +264,19 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
});
const unlistenBlur = await listen("tauri://blur", () => {
console.log("Window blurred - checking visibility");
setIsWindowVisible(false);
});
const onVisibilityChange = () => {
setIsWindowVisible(!document.hidden);
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
unlistenClose();
unlistenShow();
unlistenFocus();
unlistenBlur();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
} catch (error) {
console.error("Failed to setup visibility detection:", error);
@@ -196,14 +287,41 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setupVisibilityDetection();
}, [config.rpcEnabled]);
const uiValue = useMemo(() => ({
activeView, setActiveView, showIntro, setShowIntro,
logoAnimDone, setLogoAnimDone, isUiHidden, setIsUiHidden,
isWindowVisible,
focusSection, setFocusSection,
onNavigateToSkin, onNavigateToMenu, connected,
updateMessage, updateUrl, clearUpdateMessage
}), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, updateUrl, clearUpdateMessage]);
const uiValue = useMemo(
() => ({
activeView,
setActiveView,
showIntro,
setShowIntro,
logoAnimDone,
setLogoAnimDone,
isUiHidden,
setIsUiHidden,
isWindowVisible,
focusSection,
setFocusSection,
onNavigateToSkin,
onNavigateToMenu,
connected,
updateMessage,
updateUrl,
clearUpdateMessage,
}),
[
activeView,
showIntro,
logoAnimDone,
isUiHidden,
isWindowVisible,
focusSection,
onNavigateToSkin,
onNavigateToMenu,
connected,
updateMessage,
updateUrl,
clearUpdateMessage,
],
);
return (
<UIContext.Provider value={uiValue}>
@@ -220,8 +338,28 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
);
}
export const useUI = () => { const c = useContext(UIContext); if (!c) throw new Error("useUI must be used within LauncherProvider"); return c; };
export const useConfig = () => { const c = useContext(ConfigContext); if (!c) throw new Error("useConfig must be used within LauncherProvider"); return c; };
export const useAudio = () => { const c = useContext(AudioContext); if (!c) throw new Error("useAudio must be used within LauncherProvider"); return c; };
export const useGame = () => { const c = useContext(GameContext); if (!c) throw new Error("useGame must be used within LauncherProvider"); return c; };
export const useSkin = () => { const c = useContext(SkinContext); if (!c) throw new Error("useSkin must be used within LauncherProvider"); return c; };
export const useUI = () => {
const c = useContext(UIContext);
if (!c) throw new Error("useUI must be used within LauncherProvider");
return c;
};
export const useConfig = () => {
const c = useContext(ConfigContext);
if (!c) throw new Error("useConfig must be used within LauncherProvider");
return c;
};
export const useAudio = () => {
const c = useContext(AudioContext);
if (!c) throw new Error("useAudio must be used within LauncherProvider");
return c;
};
export const useGame = () => {
const c = useContext(GameContext);
if (!c) throw new Error("useGame must be used within LauncherProvider");
return c;
};
export const useSkin = () => {
const c = useContext(SkinContext);
if (!c) throw new Error("useSkin must be used within LauncherProvider");
return c;
};
+60 -7
View File
@@ -1,5 +1,23 @@
@import "tailwindcss";
@layer base {
*,
::before,
::after {
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: auto;
}
button,
input,
select,
textarea {
font-weight: normal;
}
body {
font-synthesis: none;
}
}
@font-face {
font-family: "Mojangles";
src: url("/fonts/Mojangles.ttf") format("truetype");
@@ -7,6 +25,20 @@
font-style: normal;
}
@font-face {
font-family: "Mojang7";
src: url("/fonts/Mojang Font_7.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Mojang11";
src: url("/fonts/Mojang Font_11.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
.gamepad-connected-indicator {
position: fixed;
bottom: 10px;
@@ -122,7 +154,6 @@ body {
margin: 0;
background-color: #000;
font-family: "Mojangles", monospace;
-webkit-font-smoothing: none;
}
.mc-button {
@@ -178,7 +209,7 @@ body {
}
::-webkit-scrollbar {
width: 14px;
width: 16px;
}
::-webkit-scrollbar-track {
@@ -188,14 +219,25 @@ body {
}
::-webkit-scrollbar-thumb {
background: url("/images/Slider_Handle.png") no-repeat center;
background-size: 100% 100%;
background-image:
url("/images/Slider_Handle_top.png"),
url("/images/Slider_Handle_mid.png"),
url("/images/Slider_Handle_bottom.png");
background-repeat: no-repeat, no-repeat, no-repeat;
background-position:
50% 4px,
50% 8px,
50% 100%;
background-size:
16px 4px,
16px calc(100% - 14px),
16px 6px;
image-rendering: pixelated;
min-height: 44px;
}
.custom-scrollbar::-webkit-scrollbar {
width: 14px;
width: 16px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@@ -205,8 +247,19 @@ body {
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: url("/images/Slider_Handle.png") no-repeat center;
background-size: 100% 100%;
background-image:
url("/images/Slider_Handle_top.png"),
url("/images/Slider_Handle_mid.png"),
url("/images/Slider_Handle_bottom.png");
background-repeat: no-repeat, no-repeat, no-repeat;
background-position:
50% 4px,
50% 8px,
50% 100%;
background-size:
16px 4px,
16px calc(100% - 14px),
16px 6px;
image-rendering: pixelated;
min-height: 44px;
}
+2 -1
View File
@@ -1,4 +1,5 @@
export const SPLASHES = [
"Holy moly! - Counterract",
"Legacy is back!",
"Pixelated goodness!",
"Console Edition vibe!",
@@ -232,4 +233,4 @@ export const SPLASHES = [
"I LOVE YURI!!!!!!! oh and yaoi ig",
"RIP PrismaChunk0's Dog 2010-2026",
"Guess who's back, back again!",
];
];
+18 -1
View File
@@ -26,6 +26,11 @@ export function useAppConfig() {
const [launchPrefix, setLaunchPrefix] = useState<string | undefined>();
const [launchEnvVars, setLaunchEnvVars] = useState<Record<string, string> | undefined>();
const [skipIntro, setSkipIntro] = useLocalStorage("lce-skip-intro", false);
const [instanceLaunchArgs, setInstanceLaunchArgs] = useState<
Record<string, { values: Record<string, unknown>; args: string[] }>
>({});
const [androidRunner, setAndroidRunner] = useLocalStorage<string | undefined>("lce-android-runner", undefined);
const [androidAudioBackend, setAndroidAudioBackend] = useLocalStorage<"alsa" | "pulseaudio">("lce-android-audio", "pulseaudio");
useEffect(() => {
TauriService.loadConfig().then((config) => {
if (config.username) setUsername(config.username);
@@ -49,6 +54,9 @@ export function useAppConfig() {
if (config.launchPrefix) setLaunchPrefix(config.launchPrefix);
if (config.launchEnvVars) setLaunchEnvVars(config.launchEnvVars);
if (config.skipIntro !== undefined) setSkipIntro(config.skipIntro);
if (config.instanceLaunchArgs) setInstanceLaunchArgs(config.instanceLaunchArgs);
if (config.androidRunner) setAndroidRunner(config.androidRunner);
if (config.androidAudioBackend) setAndroidAudioBackend(config.androidAudioBackend);
setIsLoaded(true);
});
}, []);
@@ -76,9 +84,12 @@ export function useAppConfig() {
launchPrefix,
launchEnvVars,
skipIntro,
instanceLaunchArgs,
androidRunner,
androidAudioBackend,
}).catch(console.error);
}
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded]);
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded, instanceLaunchArgs, androidRunner, androidAudioBackend]);
return {
username,
@@ -128,5 +139,11 @@ export function useAppConfig() {
setLaunchEnvVars,
skipIntro,
setSkipIntro,
instanceLaunchArgs,
setInstanceLaunchArgs,
androidRunner,
setAndroidRunner,
androidAudioBackend,
setAndroidAudioBackend,
};
}
+3 -1
View File
@@ -30,7 +30,9 @@ export function useAudioController({
isWindowVisible,
}: AudioControllerProps) {
const [currentTrack, setCurrentTrack] = useState(0);
const [splashIndex, setSplashIndex] = useState(-1);
const [splashIndex, setSplashIndex] = useState(
() => Math.floor(Math.random() * SPLASHES.length),
);
const audioContextRef = useRef<AudioContext | null>(null);
const musicSourceRef = useRef<AudioBufferSourceNode | null>(null);
const musicGainRef = useRef<GainNode | null>(null);
+16 -1
View File
@@ -78,7 +78,22 @@ export function useDiscordRPC({
details = tabNames[activeView] || "In Menus";
}
await RpcService.updateActivity(details, state, isGameRunning, username);
const skinUrl = (() => {
try {
return (
JSON.parse(localStorage.getItem("lce-skin") || "null") || undefined
);
} catch {
return undefined;
}
})();
await RpcService.updateActivity(
details,
state,
isGameRunning,
username,
skinUrl,
);
};
updateRPC();
+87 -40
View File
@@ -9,8 +9,9 @@ import {
} from "react";
import { TauriService, type CustomEdition } from "../services/TauriService";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { usePlatform } from "./usePlatform";
import { HIDDEN_INSTANCE_URL } from "../types/edition";
import type { Edition } from "../types/edition";
async function imageUrlToBase64(url: string): Promise<string> {
const response = await fetch(url);
const blob = await response.blob();
@@ -30,7 +31,7 @@ export const BASE_EDITIONS = [
id: "legacy_evolved",
name: "neoLegacy",
desc: "Backporting newer title updates and Minigames back to LCE",
url: "https://bucket.ibatv.xyz/neolegacy/Release.zip",
url: "https://git.neolegacy.dev/neoStudiosLCE/neoLegacy/releases/download/latest/neoLegacyWindows64.zip", //neo: fuck IBA (Julia)
titleImage: "/images/minecraft_title_neoLegacy.png",
supportsSlimSkins: true,
logo: "/images/neoLegacy.png",
@@ -47,6 +48,28 @@ export const BASE_EDITIONS = [
panorama: "vanilla_tu24",
logo: "/images/revelations.png",
},
/*{
id: "cafeberry",
name: "Cafeberry",
desc: "Project aiming to faithfully backport TUs, add cross-play and more!",
url: "https://gitea.str1k3r.xyz/cafeberry/cafeberry/releases/download/latest/LCEWindows64.zip",
titleImage: "/images/cafeberry_title.png",
supportsSlimSkins: false,
logo: "", //neo: TODO: add Cafeberry logo
panorama: "vanilla_tu24",
lceOnline: false, //neo: for now.
},*/
{
id: "lostlegacy",
name: "Project Lost Legacy",
desc: "Downporting project aiming at bringing back the feel of Title Update 1 with extra QoL features.",
url: "no", //neo: TODO: update url when sails makes it public
titleImage: "/images/lostlegacy_title.png",
logo: "/images/lostlegacy.png",
supportsSlimSkins: false,
panorama: "vanilla_tu1",
comingSoon: true,
},
{
id: "360revived",
name: "360 Revived",
@@ -56,6 +79,7 @@ export const BASE_EDITIONS = [
supportsSlimSkins: false,
logo: "/images/360_revived.png",
panorama: "360revived",
hideOnAndroid: true, //neo: 360revived shows a black screen on Android
},
{
id: "legacy_nether_fork",
@@ -78,16 +102,27 @@ export const BASE_EDITIONS = [
panorama: "moonedition",
},
{
//neo: disabled.
id: "lceonline",
name: "LCE Online Client",
desc: "Restoring the classic LCE online experience with friends, world hosting, leaderboards & more.",
url: "https://github.com/lceonline/MCLEClient/releases/latest/download/LCENWindows64.zip",
desc: "[DISCONTINUED!] Restoring the classic LCE online experience with friends, world hosting, leaderboards & more.",
url: HIDDEN_INSTANCE_URL, //neo: was "https://github.com/lceonline/MCLEClient/releases/latest/download/LCENWindows64.zip"
titleImage: "/images/lceonline.png",
supportsSlimSkins: false,
logo: "/images/lce_online.png",
panorama: "vanilla_tu19",
lceOnline: true,
},
{
id: "amythest",
name: "Amethyst LCE",
desc: "A project aimed towards backporting modern Java edition features and their feel into LCE! ",
logo: "/images/amythest.png",
panorama: "vanilla_tu24", //neo: TODO: use the Amythest's panorama
supportsSlimSkins: false, //neo: TODO: check properly lol
titleImage: "/images/amythest_title.png",
url: "https://github.com/ducttapesucker9000-svg/Amethyst_Source/releases/download/latest/Amethyst-Windows-Release.zip",
},
];
const PARTNERSHIP_SERVERS = [
@@ -105,7 +140,7 @@ const PARTNERSHIP_SERVERS = [
name: "LapboardMC",
ip: "104.168.125.227",
port: 4444,
}
},
];
interface GameManagerProps {
@@ -149,6 +184,7 @@ export function useGameManager({
setCustomizations,
extraLaunchArgs,
}: GameManagerProps) {
const { isAndroid } = usePlatform();
const [installs, setInstalls] = useState<string[]>([]);
const [isGameRunning, setIsGameRunning] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<
@@ -283,36 +319,38 @@ export function useGameManager({
const editions = useMemo((): Edition[] => {
return [
...BASE_EDITIONS.map((e) => {
const availableBranches = branches[e.id] || ["Stable"];
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
let url = dynamicUrls[e.id] || e.url;
const defaultBranchFromUrl = e.url.includes("/releases/download/")
? e.url.split("/releases/download/")[1].split("/")[0]
: "nightly";
const branchToUse =
selectedBranch === "Stable"
? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl
: selectedBranch;
if (e.url.includes("/releases/download/")) {
const baseUrl = e.url.split("/releases/download/")[0];
const filename = e.url.split("/").pop();
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
}
...BASE_EDITIONS.filter((e) => !(isAndroid && e.hideOnAndroid)).map(
(e) => {
const availableBranches = branches[e.id] || ["Stable"];
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
let url = dynamicUrls[e.id] || e.url;
const defaultBranchFromUrl = e.url.includes("/releases/download/")
? e.url.split("/releases/download/")[1].split("/")[0]
: "nightly";
const branchToUse =
selectedBranch === "Stable"
? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl
: selectedBranch;
if (e.url.includes("/releases/download/")) {
const baseUrl = e.url.split("/releases/download/")[0];
const filename = e.url.split("/").pop();
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
}
const edition = {
...e,
url,
branches: availableBranches,
selectedBranch,
instanceId:
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
};
const custom = customizations[e.id];
if (custom?.titleImage) edition.titleImage = custom.titleImage;
if (custom?.panorama) edition.panorama = custom.panorama;
return edition;
}),
const edition = {
...e,
url,
branches: availableBranches,
selectedBranch,
instanceId:
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
};
const custom = customizations[e.id];
if (custom?.titleImage) edition.titleImage = custom.titleImage;
if (custom?.panorama) edition.panorama = custom.panorama;
return edition;
},
),
...customEditions.map((e) => {
const edition: Edition = { ...e, instanceId: e.id };
const custom = customizations[e.id];
@@ -321,7 +359,14 @@ export function useGameManager({
return edition;
}),
];
}, [customEditions, dynamicUrls, branches, selectedBranches, customizations]);
}, [
customEditions,
dynamicUrls,
branches,
selectedBranches,
customizations,
isAndroid,
]);
const checkInstalls = useCallback(async () => {
const results = await Promise.all(
@@ -341,6 +386,8 @@ export function useGameManager({
editions.map(async (edition) => {
if (!installs.includes(edition.instanceId))
return [edition.instanceId, false] as const;
if (edition.url === HIDDEN_INSTANCE_URL)
return [edition.instanceId, false] as const;
try {
const isUpdate = await TauriService.checkGameUpdate(
edition.instanceId,
@@ -401,7 +448,7 @@ export function useGameManager({
gameLogRef.current = true;
setError(null);
setGameLog(log);
getCurrentWindow().unminimize();
if (!isAndroid) getCurrentWindow().unminimize();
});
return () => {
unlistenDownload.then((u) => u());
@@ -410,7 +457,7 @@ export function useGameManager({
unlistenRetry.then((u) => u());
unlistenGameLog.then((u) => u());
};
}, [customEditions, checkInstalls]);
}, [customEditions, checkInstalls, isAndroid]);
const downloadRunner = useCallback(
async (name: string, url: string) => {
@@ -508,7 +555,7 @@ export function useGameManager({
setError(null);
setIsGameRunning(true);
try {
getCurrentWindow().minimize();
if (!isAndroid) getCurrentWindow().minimize();
const currentEdition = editions.find((e) => e.instanceId === profile);
await TauriService.launchGame(
profile,
@@ -537,7 +584,7 @@ export function useGameManager({
} finally {
setIsGameRunning(false);
}
}, [isGameRunning, profile, extraLaunchArgs]);
}, [isGameRunning, profile, extraLaunchArgs, isAndroid]);
const stopGame = useCallback(async () => {
try {
@@ -682,4 +729,4 @@ export function useGameManager({
updateCustomization,
saveCustomPath,
};
}
}
+22 -7
View File
@@ -1,9 +1,20 @@
import { useEffect, useRef, useState, useCallback } from 'react';
export interface UseGamepadProps {
playSfx: (file: string) => void;
isWindowVisible: boolean;
}
const KEY_CODE_MAP: Record<string, number> = {
Enter: 13, Escape: 27, Tab: 9,
ArrowDown: 40, ArrowUp: 38, ArrowLeft: 37, ArrowRight: 39,
};
const CODE_MAP: Record<string, string> = {
Enter: 'Enter', Escape: 'Escape', Tab: 'Tab',
ArrowDown: 'ArrowDown', ArrowUp: 'ArrowUp', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
};
export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
const [connected, setConnected] = useState(false);
const requestRef = useRef<number | undefined>(undefined);
@@ -32,12 +43,16 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
}, [playSfx]);
const dispatchKey = (key: string, shiftKey = false) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key, shiftKey, bubbles: true, cancelable: true, view: window
}));
window.dispatchEvent(new KeyboardEvent('keyup', {
key, shiftKey, bubbles: true, cancelable: true, view: window
}));
const code = CODE_MAP[key] ?? key;
const keyCode = KEY_CODE_MAP[key] ?? 0;
const opts = {
key, code, keyCode, which: keyCode, shiftKey,
bubbles: true, cancelable: true, view: window,
};
window.dispatchEvent(new KeyboardEvent('keydown', opts));
setTimeout(() => {
window.dispatchEvent(new KeyboardEvent('keyup', opts));
}, 10);
};
const update = useCallback(() => {
@@ -120,4 +135,4 @@ export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
}, [connected, update, isWindowVisible]);
return { connected };
};
};
+3 -3
View File
@@ -20,7 +20,7 @@ export function useLceOnlineNotifications() {
requestsData.requests.forEach((r) => {
if (!seenRequests.current.has(r.username)) {
seenRequests.current.add(r.username);
setFriendRequestMessage(`${r.displayName} wants to be friends!`);
setFriendRequestMessage(`${r.displayName || r.username} wants to be friends!`);
}
});
} catch (e) {}
@@ -30,7 +30,7 @@ export function useLceOnlineNotifications() {
invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.displayName} invited you to play!`);
setInviteMessage(`${i.from.displayName || i.from.username} invited you to play!`);
}
});
} catch {}
@@ -53,7 +53,7 @@ export function useLceOnlineNotifications() {
invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.displayName} invited you to play!`);
setInviteMessage(`${i.from.displayName || i.from.username} invited you to play!`);
}
});
} catch {}
+6 -5
View File
@@ -1,13 +1,14 @@
import { useMemo } from 'react';
export function usePlatform() {
const platform = useMemo(() => {
if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: false };
if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: false, isAndroid: false };
const ua = window.navigator.userAgent.toLowerCase();
const plat = window.navigator.platform.toLowerCase();
const isLinux = plat.includes('linux') || ua.includes('linux');
const isMac = plat.includes('mac') || ua.includes('mac');
const isWindows = plat.includes('win') || ua.includes('win');
return { isLinux, isMac, isWindows };
const isAndroid = ua.includes('android');
const isLinux = !isAndroid && (plat.includes('linux') || ua.includes('linux'));
const isMac = !isAndroid && (plat.includes('mac') || ua.includes('mac'));
const isWindows = !isAndroid && (plat.includes('win') || ua.includes('win'));
return { isLinux, isMac, isWindows, isAndroid };
}, []);
return platform;
+94 -37
View File
@@ -261,19 +261,19 @@ export default function App() {
if (unlistenEvent) unlistenEvent();
};
}, [queueDeepLink]);
const { isMac } = usePlatform();
const { isMac, isAndroid } = usePlatform();
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const appWindow = getCurrentWindow();
if (!isMac) appWindow.setDecorations(false);
if (!isMac && !isAndroid) appWindow.setDecorations(false);
const checkFs = async () => setIsFullscreen(await appWindow.isFullscreen());
checkFs();
const unlisten = appWindow.onResized(checkFs);
return () => {
unlisten.then((fn: () => void) => fn());
};
}, [isMac]);
const showHeader = !isMac || isFullscreen;
}, [isMac, isAndroid]);
const showHeader = (!isMac || isFullscreen) && !isAndroid;
useEffect(() => {
if (config.isLoaded) {
const setupCompleted =
@@ -290,6 +290,21 @@ export default function App() {
const selectedVersionName = selectedEdition?.name ?? "";
const hasAnyInstall = game.installs.length > 0;
const titleImage = selectedEdition?.titleImage ?? "/images/MenuTitle.png";
const TITLE_HIDDEN_VIEWS = new Set([
//neo: why an entire Set for that? yes. the answer is yes.
"workshop",
"lceonline",
"devtools",
"guides",
"pck-editor",
"arc-editor",
"loc-editor",
"grf-editor",
"col-editor",
"options-editor",
"model-editor",
"swf-editor",
]);
useEffect(() => {
const handleContextMenu = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", handleContextMenu);
@@ -455,7 +470,7 @@ export default function App() {
animate={{ opacity: 1 }}
className={`flex flex-col h-full z-10 w-full relative ${showHeader ? "pt-12" : ""}`}
>
{!config.legacyMode && (
{!config.legacyMode && !isAndroid && (
<motion.div {...uiFade} className="absolute top-10 left-8 z-50">
<button
onClick={() => {
@@ -477,6 +492,49 @@ export default function App() {
</motion.div>
)}
{isAndroid && activeView !== "main" && (
<motion.div {...uiFade} className="absolute top-10 left-8 z-50">
<button
onClick={() => {
audio.playBackSound();
setActiveView("main");
}}
className="outline-none border-none flex items-center justify-center w-10 h-10 cursor-pointer"
aria-label="Back"
style={{
backgroundImage: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onMouseEnter={(e) =>
((
e.currentTarget as HTMLButtonElement
).style.backgroundImage =
"url('/images/Button_Square_Highlighted.png')")
}
onMouseLeave={(e) =>
((
e.currentTarget as HTMLButtonElement
).style.backgroundImage = "url('/images/Button_Square.png')")
}
>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="#FFFFFF"
strokeWidth="2.5"
strokeLinecap="square"
strokeLinejoin="miter"
className="drop-shadow-[2px_2px_0_rgba(0,0,0,0.8)]"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</motion.div>
)}
{!config.legacyMode && (
<motion.div
{...uiFade}
@@ -539,35 +597,37 @@ export default function App() {
<div className="shrink-0 flex justify-center py-4 relative w-full pt-4">
<div className="relative w-full max-w-135 flex justify-center">
{activeView !== "credits" && (
<motion.img
layoutId="mainLogo"
src={titleImage}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
}}
className="w-full drop-shadow-[0_8px_6px_rgba(0,0,0,0.8)] pointer-events-none"
style={{ imageRendering: "pixelated" }}
/>
)}
{activeView !== "credits" && (
<motion.div
{...uiFade}
className="absolute bottom-[20%] right-[5%] w-0 h-0 flex items-center justify-center"
>
<div
onClick={audio.cycleSplash}
className="mc-splash text-[#FFFF55] text-[28px] z-100 cursor-pointer whitespace-nowrap"
style={{ textShadow: "2px 2px 0px #3F3F00" }}
{activeView !== "credits" &&
!TITLE_HIDDEN_VIEWS.has(activeView) && (
<motion.img
layoutId="mainLogo"
src={titleImage}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
}}
className="w-full drop-shadow-[0_8px_6px_rgba(0,0,0,0.8)] pointer-events-none"
style={{ imageRendering: "pixelated" }}
/>
)}
{activeView !== "credits" &&
!TITLE_HIDDEN_VIEWS.has(activeView) && (
<motion.div
{...uiFade}
className="absolute bottom-[20%] right-[5%] w-0 h-0 flex items-center justify-center"
>
{audio.splashIndex === -1
? `Welcome ${config.username}!`
: audio.splashes[audio.splashIndex]}
</div>
</motion.div>
)}
<div
onClick={audio.cycleSplash}
className="mc-splash text-[#FFFF55] text-[28px] z-100 cursor-pointer whitespace-nowrap"
style={{ textShadow: "2px 2px 0px #3F3F00" }}
>
{audio.splashIndex === -1
? `Welcome ${config.username}!`
: audio.splashes[audio.splashIndex]}
</div>
</motion.div>
)}
{activeView === "main" &&
hasAnyInstall &&
titleImage === "/images/MenuTitle.png" && (
@@ -599,6 +659,7 @@ export default function App() {
setIsUiHidden={setIsUiHidden}
isFocusedSection={focusSection === "skin"}
onNavigateRight={onNavigateToMenu}
slim={skin.skinIsSlim}
/>
)}
</AnimatePresence>
@@ -681,10 +742,6 @@ export default function App() {
<div className="flex-1 text-left whitespace-nowrap">
Version: {pkg.version} ({__BUILD_DATE__})
</div>
<div className="flex-[2] text-center whitespace-nowrap">
Not affiliated with Mojang AB or Microsoft. "Minecraft" is a
trademark of Mojang Synergies AB.
</div>
<div className="flex-1 text-right whitespace-nowrap">
{connected && "CONTROLLER CONNECTED"}
</div>
+12 -11
View File
@@ -83,7 +83,7 @@ export class LceOnlineService {
const res = await this.request<string>(
"POST",
"/login",
`${username}:${password}`,
`${JSON.stringify({ username: username, password: password })}`,
AUTH_BASE_URL,
);
const text = typeof res === "string" ? res : "";
@@ -98,7 +98,7 @@ export class LceOnlineService {
const res = await this.request<string>(
"POST",
"/register",
`${username}:${password}`,
`${JSON.stringify({ username: username, password: password })}`,
AUTH_BASE_URL,
);
const text = typeof res === "string" ? res : "";
@@ -128,9 +128,10 @@ export class LceOnlineService {
this._notify();
try {
const raw: string = await this.request<string>("POST", "/accountinfo");
if (typeof raw === "string" && raw.startsWith("-")) {
const username = raw.slice(1);
this._session!.account = { username, displayName: username };
if (typeof raw === "object" && raw !== null) {
const data = JSON.parse(raw);
const username = data.username;
this._session!.account = { username, displayName: data.displayName };
this.saveSession();
this._notify();
}
@@ -166,7 +167,7 @@ export class LceOnlineService {
): Promise<T> {
const headers: Record<string, string> = {
Accept: "text/plain, application/json",
"User-Agent": "MCLCE-LCEOnline/1.0",
"User-Agent": "MCLCE-LCEOnline/1.1", // str1k3r - changed the user agent to use 1.1 so older versions use older apis & get older responses.
};
if (body) {
@@ -217,9 +218,9 @@ export class LceOnlineService {
try {
const res = await this.request<string>("POST", "/refreshtoken", null, AUTH_BASE_URL);
if (typeof res === "string" && res.startsWith("-")) {
const [username, token] = res.slice(1).split(":");
this._session.accessToken = token;
this._session.account = { username, displayName: username };
const data = JSON.parse(res);
this._session.accessToken = data.token;
this._session.account = { username: data.username, displayName: data.displayName };
this.saveSession();
this._notify();
return true;
@@ -273,7 +274,7 @@ export class LceOnlineService {
async sendInvite(target: string): Promise<void> {
const res = await this.request<string>("POST", "/invite", target);
if (typeof res === "string" && res !== "Invite Sent") {
if (typeof res === "string" && res !== "Successfully Sent Invite") {
throw new Error(res);
}
}
@@ -289,7 +290,7 @@ export class LceOnlineService {
await this.request("POST", "/declineinvite", from);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "";
if (msg !== "Declined Invite") throw e;
if (msg !== "Successfully Declined Invite") throw e;
}
}
+87 -84
View File
@@ -1,14 +1,17 @@
import { OptionsFile } from "../types/options";
export class OptionsService {
public static readOptions(buffer: ArrayBuffer, endianness: "little" | "big" = "little"): OptionsFile {
public static readOptions(
buffer: ArrayBuffer,
endianness: "little" | "big" = "little",
): OptionsFile {
const rawData = new Uint8Array(buffer).slice();
const view = new DataView(buffer);
const little = endianness === "little";
const opt: Partial<OptionsFile> = {
endianness,
rawData
rawData,
};
//neo: someone save me from these values
opt.musicVolume = view.getUint8(0x01);
opt.soundVolume = view.getUint8(0x02);
opt.gameSensitivity = view.getUint8(0x03);
@@ -24,7 +27,7 @@ export class OptionsService {
opt.verticalSplitscreen = (val06 & (1 << 8)) !== 0;
opt.splitscreenGamertags = (val06 & (1 << 9)) !== 0;
opt.hints = (val06 & (1 << 10)) !== 0;
opt.autosaveTimer = (val06 >> 11) & 0xF;
opt.autosaveTimer = (val06 >> 11) & 0xf;
opt.inGameTooltips = (val06 & (1 << 15)) !== 0;
const val54 = view.getUint32(0x54, little);
opt.renderClouds = (val54 & (1 << 0)) !== 0;
@@ -45,46 +48,46 @@ export class OptionsService {
opt.displaySaveIcon = (val54 & (1 << 29)) !== 0;
opt.flyingViewRolling = (val54 & (1 << 30)) === 0;
opt.showGlideGhostPath = (val54 & (1 << 31)) !== 0;
opt.chosenSkin = view.getUint32(0x4C, little);
opt.playerCape = view.getUint32(0x5C, little);
opt.chosenSkin = view.getUint32(0x4c, little);
opt.playerCape = view.getUint32(0x5c, little);
opt.favoriteSkins = [];
for (let i = 0; i < 10; i++) {
opt.favoriteSkins.push(view.getUint32(0x60 + i * 4, little));
}
opt.actions = {
jump: view.getUint8(0xA4),
use: view.getUint8(0xA5),
action: view.getUint8(0xA6),
cycleHeldItemLeft: view.getUint8(0xA7),
cycleHeldItemRight: view.getUint8(0xA8),
inventory: view.getUint8(0xA9),
drop: view.getUint8(0xAA),
sneakDismount: view.getUint8(0xAB),
crafting: view.getUint8(0xAC),
changeCameraMode: view.getUint8(0xAD),
flyLeft: view.getUint8(0xAE),
flyRight: view.getUint8(0xAF),
flyUp: view.getUint8(0xB0),
flyDown: view.getUint8(0xB1),
sprint: view.getUint8(0xB2),
pickBlock: view.getUint8(0xB3),
previousPlayer: view.getUint8(0xB4),
nextPlayer: view.getUint8(0xB5),
spectateNoise: view.getUint8(0xB6),
cancelSpectating: view.getUint8(0xB7),
confirmReady: view.getUint8(0xB8),
vote: view.getUint8(0xB9),
restartSection: view.getUint8(0xBA),
restartRace: view.getUint8(0xBB),
lookBehind: view.getUint8(0xBC)
jump: view.getUint8(0xa4),
use: view.getUint8(0xa5),
action: view.getUint8(0xa6),
cycleHeldItemLeft: view.getUint8(0xa7),
cycleHeldItemRight: view.getUint8(0xa8),
inventory: view.getUint8(0xa9),
drop: view.getUint8(0xaa),
sneakDismount: view.getUint8(0xab),
crafting: view.getUint8(0xac),
changeCameraMode: view.getUint8(0xad),
flyLeft: view.getUint8(0xae),
flyRight: view.getUint8(0xaf),
flyUp: view.getUint8(0xb0),
flyDown: view.getUint8(0xb1),
sprint: view.getUint8(0xb2),
pickBlock: view.getUint8(0xb3),
previousPlayer: view.getUint8(0xb4),
nextPlayer: view.getUint8(0xb5),
spectateNoise: view.getUint8(0xb6),
cancelSpectating: view.getUint8(0xb7),
confirmReady: view.getUint8(0xb8),
vote: view.getUint8(0xb9),
restartSection: view.getUint8(0xba),
restartRace: view.getUint8(0xbb),
lookBehind: view.getUint8(0xbc),
};
return opt as OptionsFile;
}
public static serializeOptions(opt: OptionsFile): ArrayBuffer {
const minSize = 0xBC + 1;
const minSize = 0xbc + 1;
const buffer = new Uint8Array(Math.max(opt.rawData.length, minSize));
buffer.set(opt.rawData);
const view = new DataView(buffer.buffer);
@@ -97,69 +100,69 @@ export class OptionsService {
view.setUint8(0x51, opt.interfaceOpacity);
let val06 = view.getUint16(0x06, little);
val06 = (val06 & ~0x3) | (opt.difficulty & 0x3);
val06 = (val06 & ~(1 << 2)) | (opt.viewBobbing ? (1 << 2) : 0);
val06 = (val06 & ~(1 << 3)) | (opt.inGameGamertags ? (1 << 3) : 0);
val06 = (val06 & ~(1 << 6)) | (opt.invertLook ? (1 << 6) : 0);
val06 = (val06 & ~(1 << 7)) | (opt.southpaw ? (1 << 7) : 0);
val06 = (val06 & ~(1 << 8)) | (opt.verticalSplitscreen ? (1 << 8) : 0);
val06 = (val06 & ~(1 << 9)) | (opt.splitscreenGamertags ? (1 << 9) : 0);
val06 = (val06 & ~(1 << 10)) | (opt.hints ? (1 << 10) : 0);
val06 = (val06 & ~(0xF << 11)) | ((opt.autosaveTimer & 0xF) << 11);
val06 = (val06 & ~(1 << 15)) | (opt.inGameTooltips ? (1 << 15) : 0);
val06 = (val06 & ~(1 << 2)) | (opt.viewBobbing ? 1 << 2 : 0);
val06 = (val06 & ~(1 << 3)) | (opt.inGameGamertags ? 1 << 3 : 0);
val06 = (val06 & ~(1 << 6)) | (opt.invertLook ? 1 << 6 : 0);
val06 = (val06 & ~(1 << 7)) | (opt.southpaw ? 1 << 7 : 0);
val06 = (val06 & ~(1 << 8)) | (opt.verticalSplitscreen ? 1 << 8 : 0);
val06 = (val06 & ~(1 << 9)) | (opt.splitscreenGamertags ? 1 << 9 : 0);
val06 = (val06 & ~(1 << 10)) | (opt.hints ? 1 << 10 : 0);
val06 = (val06 & ~(0xf << 11)) | ((opt.autosaveTimer & 0xf) << 11);
val06 = (val06 & ~(1 << 15)) | (opt.inGameTooltips ? 1 << 15 : 0);
view.setUint16(0x06, val06, little);
let val54 = view.getUint32(0x54, little);
val54 = (val54 & ~(1 << 0)) | (opt.renderClouds ? (1 << 0) : 0);
val54 = (val54 & ~(1 << 7)) | (opt.displayHud ? (1 << 7) : 0);
val54 = (val54 & ~(1 << 8)) | (opt.displayHand ? (1 << 8) : 0);
val54 = (val54 & ~(1 << 9)) | (opt.customSkinAnimation ? (1 << 9) : 0);
val54 = (val54 & ~(1 << 10)) | (opt.deathMessages ? (1 << 10) : 0);
val54 = (val54 & ~(1 << 0)) | (opt.renderClouds ? 1 << 0 : 0);
val54 = (val54 & ~(1 << 7)) | (opt.displayHud ? 1 << 7 : 0);
val54 = (val54 & ~(1 << 8)) | (opt.displayHand ? 1 << 8 : 0);
val54 = (val54 & ~(1 << 9)) | (opt.customSkinAnimation ? 1 << 9 : 0);
val54 = (val54 & ~(1 << 10)) | (opt.deathMessages ? 1 << 10 : 0);
val54 = (val54 & ~(0x3 << 11)) | ((opt.hudSize & 0x3) << 11);
val54 = (val54 & ~(0x3 << 13)) | ((opt.hudSizeSplitscreen & 0x3) << 13);
val54 = (val54 & ~(1 << 15)) | (opt.animatedCharacter ? (1 << 15) : 0);
val54 = (val54 & ~(1 << 18)) | (opt.classicCrafting ? (1 << 18) : 0);
val54 = (val54 & ~(1 << 19)) | (opt.caveSounds ? (1 << 19) : 0);
val54 = (val54 & ~(1 << 20)) | (opt.gameChat ? (1 << 20) : 0);
val54 = (val54 & ~(1 << 21)) | (opt.minecartSounds ? (1 << 21) : 0);
val54 = (val54 & ~(1 << 22)) | (opt.showGlideGhost ? (1 << 22) : 0);
val54 = (val54 & ~(1 << 26)) | (opt.autoJump ? (1 << 26) : 0);
val54 = (val54 & ~(1 << 28)) | (opt.displayGameMessages ? (1 << 28) : 0);
val54 = (val54 & ~(1 << 29)) | (opt.displaySaveIcon ? (1 << 29) : 0);
val54 = (val54 & ~(1 << 30)) | (!opt.flyingViewRolling ? (1 << 30) : 0);
val54 = (val54 & ~(1 << 31)) | (opt.showGlideGhostPath ? (1 << 31) : 0);
val54 = (val54 & ~(1 << 15)) | (opt.animatedCharacter ? 1 << 15 : 0);
val54 = (val54 & ~(1 << 18)) | (opt.classicCrafting ? 1 << 18 : 0);
val54 = (val54 & ~(1 << 19)) | (opt.caveSounds ? 1 << 19 : 0);
val54 = (val54 & ~(1 << 20)) | (opt.gameChat ? 1 << 20 : 0);
val54 = (val54 & ~(1 << 21)) | (opt.minecartSounds ? 1 << 21 : 0);
val54 = (val54 & ~(1 << 22)) | (opt.showGlideGhost ? 1 << 22 : 0);
val54 = (val54 & ~(1 << 26)) | (opt.autoJump ? 1 << 26 : 0);
val54 = (val54 & ~(1 << 28)) | (opt.displayGameMessages ? 1 << 28 : 0);
val54 = (val54 & ~(1 << 29)) | (opt.displaySaveIcon ? 1 << 29 : 0);
val54 = (val54 & ~(1 << 30)) | (!opt.flyingViewRolling ? 1 << 30 : 0);
val54 = (val54 & ~(1 << 31)) | (opt.showGlideGhostPath ? 1 << 31 : 0);
view.setUint32(0x54, val54, little);
view.setUint32(0x4C, opt.chosenSkin, little);
view.setUint32(0x5C, opt.playerCape, little);
view.setUint32(0x4c, opt.chosenSkin, little);
view.setUint32(0x5c, opt.playerCape, little);
for (let i = 0; i < 10; i++) {
if (opt.favoriteSkins[i] !== undefined) {
view.setUint32(0x60 + i * 4, opt.favoriteSkins[i], little);
}
}
view.setUint8(0xA4, opt.actions.jump);
view.setUint8(0xA5, opt.actions.use);
view.setUint8(0xA6, opt.actions.action);
view.setUint8(0xA7, opt.actions.cycleHeldItemLeft);
view.setUint8(0xA8, opt.actions.cycleHeldItemRight);
view.setUint8(0xA9, opt.actions.inventory);
view.setUint8(0xAA, opt.actions.drop);
view.setUint8(0xAB, opt.actions.sneakDismount);
view.setUint8(0xAC, opt.actions.crafting);
view.setUint8(0xAD, opt.actions.changeCameraMode);
view.setUint8(0xAE, opt.actions.flyLeft);
view.setUint8(0xAF, opt.actions.flyRight);
view.setUint8(0xB0, opt.actions.flyUp);
view.setUint8(0xB1, opt.actions.flyDown);
view.setUint8(0xB2, opt.actions.sprint);
view.setUint8(0xB3, opt.actions.pickBlock);
view.setUint8(0xB4, opt.actions.previousPlayer);
view.setUint8(0xB5, opt.actions.nextPlayer);
view.setUint8(0xB6, opt.actions.spectateNoise);
view.setUint8(0xB7, opt.actions.cancelSpectating);
view.setUint8(0xB8, opt.actions.confirmReady);
view.setUint8(0xB9, opt.actions.vote);
view.setUint8(0xBA, opt.actions.restartSection);
view.setUint8(0xBB, opt.actions.restartRace);
view.setUint8(0xBC, opt.actions.lookBehind);
view.setUint8(0xa4, opt.actions.jump);
view.setUint8(0xa5, opt.actions.use);
view.setUint8(0xa6, opt.actions.action);
view.setUint8(0xa7, opt.actions.cycleHeldItemLeft);
view.setUint8(0xa8, opt.actions.cycleHeldItemRight);
view.setUint8(0xa9, opt.actions.inventory);
view.setUint8(0xaa, opt.actions.drop);
view.setUint8(0xab, opt.actions.sneakDismount);
view.setUint8(0xac, opt.actions.crafting);
view.setUint8(0xad, opt.actions.changeCameraMode);
view.setUint8(0xae, opt.actions.flyLeft);
view.setUint8(0xaf, opt.actions.flyRight);
view.setUint8(0xb0, opt.actions.flyUp);
view.setUint8(0xb1, opt.actions.flyDown);
view.setUint8(0xb2, opt.actions.sprint);
view.setUint8(0xb3, opt.actions.pickBlock);
view.setUint8(0xb4, opt.actions.previousPlayer);
view.setUint8(0xb5, opt.actions.nextPlayer);
view.setUint8(0xb6, opt.actions.spectateNoise);
view.setUint8(0xb7, opt.actions.cancelSpectating);
view.setUint8(0xb8, opt.actions.confirmReady);
view.setUint8(0xb9, opt.actions.vote);
view.setUint8(0xba, opt.actions.restartSection);
view.setUint8(0xbb, opt.actions.restartRace);
view.setUint8(0xbc, opt.actions.lookBehind);
return buffer.buffer;
}
}
+110 -12
View File
@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { setActivity, start, clearActivity, stop } from "tauri-plugin-drpc";
import {
Activity,
@@ -11,6 +12,95 @@ class RPC {
private startTime: number = Date.now();
private initializationPromise: Promise<void> | null = null;
private initialized: boolean = false;
private headUrlCache: Map<string, string> = new Map();
private headUploadPending: Map<string, Promise<string | null>> = new Map();
private async renderHeadBlob(skinUrl: string): Promise<Blob | null> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(null);
return;
}
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, 64, 64);
if (img.height !== 32) {
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, 64, 64);
}
canvas.toBlob((blob) => {
resolve(blob);
}, "image/png");
};
img.onerror = () => {
resolve(null);
};
img.src = skinUrl;
});
}
private async uploadHead(skinUrl: string): Promise<string | null> {
const cached = this.headUrlCache.get(skinUrl);
if (cached) {
return cached;
}
const pending = this.headUploadPending.get(skinUrl);
if (pending) {
return pending;
}
const uploadPromise = (async (): Promise<string | null> => {
const blob = await this.renderHeadBlob(skinUrl);
if (!blob) {
return null;
}
const formData = new FormData();
formData.append("file", blob, "head.png");
formData.append("expire", "21600");
try {
const res = await fetch("https://tmpfiles.org/api/v1/upload", {
method: "POST",
body: formData,
});
const json = await res.json();
if (json.status === "success" && json.data?.url) {
const pageUrl = json.data.url;
const pageRes = await invoke<{ status: number; body: string }>(
"http_proxy_request",
{
method: "GET",
url: pageUrl,
body: null,
headers: {},
},
);
const html = pageRes.body;
const match = html.match(/id="img_preview"\s+src="([^"]+)"/);
if (match) {
const directUrl = match[1];
this.headUrlCache.set(skinUrl, directUrl);
return directUrl;
}
console.error(
"[RPC] uploadHead: could not extract direct image url from page",
);
}
console.error("[RPC] uploadHead: upload failed or no url in response");
} catch (e) {
console.error("[RPC] uploadHead: fetch error:", e);
}
return null;
})();
this.headUploadPending.set(skinUrl, uploadPromise);
try {
return await uploadPromise;
} finally {
this.headUploadPending.delete(skinUrl);
}
}
public async StartRPC() {
if (this.initialized) return;
if (sessionStorage.getItem("lce_rpc_started") === "true") {
@@ -38,6 +128,7 @@ class RPC {
state: string,
isPlaying: boolean = false,
username: string,
skinUrl?: string,
) {
if (!this.initialized) {
await this.StartRPC();
@@ -54,8 +145,15 @@ class RPC {
const assets = new Assets();
assets.setLargeImage("logo");
assets.setLargeText("LCE Emerald Launcher");
assets.setSmallImage("app-icon");
assets.setSmallText(isPlaying ? "Playing" : "In Menus");
const headUrl = skinUrl ? await this.uploadHead(skinUrl) : null;
console.log("[RPC] updateActivity: headUrl:", headUrl);
if (headUrl) {
assets.setSmallImage(headUrl);
assets.setSmallText(username);
} else {
assets.setSmallImage("app-icon");
assets.setSmallText(isPlaying ? "Playing" : "In Menus");
}
activity.setAssets(assets);
activity.setTimestamps(new Timestamps(this.startTime));
activity.setButton([
@@ -74,16 +172,16 @@ class RPC {
}
public async StopRPC() {
try {
await clearActivity();
await stop();
} catch (e) {
// no need to handle errors here as the launcher will close anyways!
} finally {
this.initialized = false;
this.initializationPromise = null;
sessionStorage.removeItem("lce_rpc_started");
}
try {
await clearActivity();
await stop();
} catch (e) {
// no need to handle errors here as the launcher will close anyways!
} finally {
this.initialized = false;
this.initializationPromise = null;
sessionStorage.removeItem("lce_rpc_started");
}
}
}
+67 -17
View File
@@ -17,7 +17,12 @@ export interface SwfImage {
}
export class SwfService {
static parse(buffer: Uint8Array): { version: number; compressed: boolean; frameHeader: Uint8Array; tags: SwfTag[] } {
static parse(buffer: Uint8Array): {
version: number;
compressed: boolean;
frameHeader: Uint8Array;
tags: SwfTag[];
} {
if (buffer.length < 8) throw new Error("Invalid SWF: file too small");
const sig = String.fromCharCode(buffer[0], buffer[1], buffer[2]);
const version = buffer[3];
@@ -48,7 +53,11 @@ export class SwfService {
const code = tagHeader >> 6;
let length = tagHeader & 0x3f;
if (length === 0x3f) {
length = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
length =
data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
offset += 4;
}
const tagData = data.slice(offset, offset + length);
@@ -60,7 +69,12 @@ export class SwfService {
return { version, compressed: sig === "CWS", frameHeader, tags };
}
static serialize(version: number, compressed: boolean, frameHeader: Uint8Array, tags: SwfTag[]): Uint8Array {
static serialize(
version: number,
compressed: boolean,
frameHeader: Uint8Array,
tags: SwfTag[],
): Uint8Array {
const payloads: Uint8Array[] = [frameHeader];
let bodyLength = frameHeader.length;
@@ -89,7 +103,9 @@ export class SwfService {
}
const fileLength = 8 + uncompressedBody.length;
const finalBody = compressed ? pako.deflate(uncompressedBody) : uncompressedBody;
const finalBody = compressed
? pako.deflate(uncompressedBody)
: uncompressedBody;
const result = new Uint8Array(8 + finalBody.length);
result.set(new TextEncoder().encode(compressed ? "CWS" : "FWS"), 0);
result[3] = version;
@@ -125,15 +141,32 @@ export class SwfService {
const charId = tag.data[0] | (tag.data[1] << 8);
const imgData = tag.data.slice(2);
const fixed = this.fixJpeg(imgData, tag.code === 6 ? jpegTables : null);
images.push({ id: charId, type: "jpeg", data: fixed, name: nameMap[charId] });
images.push({
id: charId,
type: "jpeg",
data: fixed,
name: nameMap[charId],
});
} else if (tag.code === 35) {
const charId = tag.data[0] | (tag.data[1] << 8);
const alphaOffset = tag.data[2] | (tag.data[3] << 8) | (tag.data[4] << 16) | (tag.data[5] << 24);
const alphaOffset =
tag.data[2] |
(tag.data[3] << 8) |
(tag.data[4] << 16) |
(tag.data[5] << 24);
const imgData = tag.data.slice(6, 6 + alphaOffset);
const alphaRaw = tag.data.slice(6 + alphaOffset);
let alpha = undefined;
try { alpha = pako.inflate(alphaRaw); } catch (e) { }
images.push({ id: charId, type: "jpeg", data: this.fixJpeg(imgData), alphaData: alpha, name: nameMap[charId] });
try {
alpha = pako.inflate(alphaRaw);
} catch (e) {}
images.push({
id: charId,
type: "jpeg",
data: this.fixJpeg(imgData),
alphaData: alpha,
name: nameMap[charId],
});
} else if (tag.code === 20 || tag.code === 36) {
const charId = tag.data[0] | (tag.data[1] << 8);
const format = tag.data[2];
@@ -144,20 +177,23 @@ export class SwfService {
images.push({
id: charId,
type: "lossless",
type: "lossless", //neo: aka PNG
data: raw,
width,
height,
format,
hasAlpha,
name: nameMap[charId]
name: nameMap[charId],
});
}
}
return images;
}
private static fixJpeg(data: Uint8Array, jtt: Uint8Array | null = null): Uint8Array {
private static fixJpeg(
data: Uint8Array,
jtt: Uint8Array | null = null,
): Uint8Array {
let combined = data;
if (jtt && jtt.length > 0) {
let tLen = jtt.length;
@@ -172,7 +208,8 @@ export class SwfService {
}
static async decodeLosslessToRGBA(img: SwfImage): Promise<Uint8Array> {
if (img.type !== "lossless" || !img.width || !img.height) return new Uint8Array(0);
if (img.type !== "lossless" || !img.width || !img.height)
return new Uint8Array(0);
const decoded = pako.inflate(img.data);
const rgba = new Uint8Array(img.width * img.height * 4);
if (img.format === 3) {
@@ -189,7 +226,7 @@ export class SwfService {
a > 0 ? Math.min(255, (r_pm * 255) / a) : 0,
a > 0 ? Math.min(255, (g_pm * 255) / a) : 0,
a > 0 ? Math.min(255, (b_pm * 255) / a) : 0,
a
a,
]);
} else {
palette.push([decoded[ptr++], decoded[ptr++], decoded[ptr++], 255]);
@@ -211,7 +248,9 @@ export class SwfService {
const rowStride = Math.ceil((img.width * 2) / 4) * 4;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const p = decoded[y * rowStride + x * 2] | (decoded[y * rowStride + x * 2 + 1] << 8);
const p =
decoded[y * rowStride + x * 2] |
(decoded[y * rowStride + x * 2 + 1] << 8);
const r = ((p >> 10) & 0x1f) << 3;
const g = ((p >> 5) & 0x1f) << 3;
const b = (p & 0x1f) << 3;
@@ -248,9 +287,20 @@ export class SwfService {
return rgba;
}
static updateImageTag(tags: SwfTag[], charId: number, newData: Uint8Array, type: SwfImage["type"]): SwfTag[] {
return tags.map(tag => {
if (tag.code === 6 || tag.code === 21 || tag.code === 35 || tag.code === 20 || tag.code === 36) {
static updateImageTag(
tags: SwfTag[],
charId: number,
newData: Uint8Array,
type: SwfImage["type"],
): SwfTag[] {
return tags.map((tag) => {
if (
tag.code === 6 ||
tag.code === 21 ||
tag.code === 35 ||
tag.code === 20 ||
tag.code === 36
) {
const id = tag.data[0] | (tag.data[1] << 8);
if (id === charId) {
let payload: Uint8Array;
+58 -17
View File
@@ -48,6 +48,12 @@ export interface AppConfig {
customizations?: Record<string, { titleImage?: string; panorama?: string }>;
customPaths?: Record<string, string>;
skipIntro?: boolean;
instanceLaunchArgs?: Record<
string,
{ values: Record<string, unknown>; args: string[] }
>;
androidRunner?: string;
androidAudioBackend?: "alsa" | "pulseaudio";
}
export interface ThemePalette {
@@ -60,7 +66,7 @@ export interface Runner {
id: string;
name: string;
path: string;
type: "wine" | "proton";
type: "wine" | "proton"; //neo: i beg you please use Proton dont use WINE, WineD3D sickens me
}
export interface MacOSSetupProgress {
@@ -127,6 +133,10 @@ export class TauriService {
return invoke("open_instance_folder", { instanceId });
}
static async openContainerSettings(instanceId: string): Promise<void> {
return invoke("open_container_settings", { instanceId });
}
static async deleteInstance(instanceId: string): Promise<void> {
return invoke("delete_instance", { instanceId });
}
@@ -188,15 +198,25 @@ export class TauriService {
return invoke("workshop_list_installed");
}
static onDownloadProgress(callback: (data: { instanceId: string; percent: number }) => void) {
return listen<{ instanceId: string; percent: number }>("download-progress", (event) =>
callback(event.payload),
static onDownloadProgress(
callback: (data: { instanceId: string; percent: number }) => void,
) {
return listen<{ instanceId: string; percent: number }>(
"download-progress",
(event) => callback(event.payload),
);
}
static onWorkshopProgress(callback: (data: { packageId: string; percent: number }) => void) {
return listen<{ instanceId: string; percent: number }>("workshop-progress", (event) =>
callback({ packageId: event.payload.instanceId, percent: event.payload.percent }),
static onWorkshopProgress(
callback: (data: { packageId: string; percent: number }) => void,
) {
return listen<{ instanceId: string; percent: number }>(
"workshop-progress",
(event) =>
callback({
packageId: event.payload.instanceId,
percent: event.payload.percent,
}),
);
}
@@ -213,27 +233,25 @@ export class TauriService {
}
static onBackendError(callback: (message: string) => void) {
return listen<string>("backend-error", (event) =>
callback(event.payload),
);
return listen<string>("backend-error", (event) => callback(event.payload));
}
static onGameLog(callback: (log: string) => void) {
return listen<string>("game-log", (event) =>
callback(event.payload),
);
return listen<string>("game-log", (event) => callback(event.payload));
}
static onDownloadRetry(callback: (attempt: number) => void) {
return listen<number>("download-retry", (event) =>
callback(event.payload),
);
return listen<number>("download-retry", (event) => callback(event.payload));
}
static async openUrl(url: string): Promise<void> {
return invoke("plugin:opener|open_url", { url });
}
static async startLceOnlineAuth(): Promise<string> {
return invoke("start_lce_auth");
}
static async restartLauncher(): Promise<void> {
return invoke("restart_launcher");
}
@@ -379,6 +397,12 @@ export class TauriService {
return invoke("get_instance_path", { instanceId });
}
static async getInstanceArgsSchema(
instanceId: string,
): Promise<string | null> {
return invoke("get_instance_args_schema", { instanceId });
}
static async readScreenshotAsDataUrl(path: string): Promise<string> {
return invoke("read_screenshot_as_data_url", { path });
}
@@ -423,7 +447,12 @@ export class TauriService {
branch: string,
dlcFolder: string,
): Promise<void> {
return invoke("download_dlc_files", { instanceId, repoUrl, branch, dlcFolder });
return invoke("download_dlc_files", {
instanceId,
repoUrl,
branch,
dlcFolder,
});
}
static async importWorld(
@@ -453,4 +482,16 @@ export class TauriService {
): Promise<string> {
return invoke("lce_to_java", { inputMsPath, javaWorldOutput });
}
static async installLatestDriver(): Promise<void> {
return invoke("install_latest_driver");
}
static async switchProton(version: string): Promise<void> {
return invoke("switch_proton", { version });
}
static async setAudioBackend(backend: string): Promise<void> {
return invoke("set_audio_backend", { backend });
}
}
+2
View File
@@ -1,3 +1,4 @@
export const HIDDEN_INSTANCE_URL = "emerald://PLEASE_IM_TIRED_OF_ALL_THIS";
export interface Edition {
id: string;
name: string;
@@ -14,6 +15,7 @@ export interface Edition {
category?: string[];
officialDLC?: string;
lceOnline?: boolean;
hideOnAndroid?: boolean;
}
export interface CustomEditionInput {
+360
View File
@@ -0,0 +1,360 @@
export type OptionType = "boolean" | "int" | "number" | "string" | "choice";
export interface SchemaChoice {
value: string;
label?: string;
}
export interface SchemaOption {
id: string;
title: string;
type: OptionType;
arg: string;
description?: string;
group?: string;
default?: unknown;
min?: number;
max?: number;
step?: number;
placeholder?: string;
choices?: SchemaChoice[];
}
export interface SchemaGroup {
id: string;
title: string;
description?: string;
}
export type Condition =
| { all: Condition[] }
| { any: Condition[] }
| { not: Condition }
| {
option: string;
equals?: unknown;
in?: unknown[];
not?: unknown;
exists?: boolean;
};
export interface SchemaDependency {
target: string;
when: Condition;
effect?: "disable" | "hide";
}
export interface ArgsSchema {
$schema: string;
schemaVersion?: number;
meta?: Record<string, unknown>;
groups: SchemaGroup[];
options: SchemaOption[];
dependencies: SchemaDependency[];
}
export interface OptionEffects {
hidden: boolean;
disabled: boolean;
}
const VALID_TYPES: OptionType[] = [
"boolean",
"int",
"number",
"string",
"choice",
];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isCondition(value: unknown): value is Condition {
if (!isObject(value)) return false;
if ("option" in value) return typeof value.option === "string";
if ("all" in value)
return Array.isArray(value.all) && value.all.every(isCondition);
if ("any" in value)
return Array.isArray(value.any) && value.any.every(isCondition);
if ("not" in value) return isCondition(value.not);
return false;
}
export function parseSchema(raw: string): ArgsSchema | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isObject(parsed)) return null;
if (parsed.$schema === undefined) return null; //neo: cheap. i know.
if (!Array.isArray(parsed.options)) return null;
const optionKeys = new Set<string>();
const options: SchemaOption[] = [];
for (const entry of parsed.options) {
if (!isObject(entry)) continue;
const id = typeof entry.id === "string" ? entry.id : "";
const title = typeof entry.title === "string" ? entry.title : "";
const type = entry.type;
const arg = typeof entry.arg === "string" ? entry.arg : "";
if (!id || !title || !arg || optionKeys.has(id)) continue;
if (typeof type !== "string" || !VALID_TYPES.includes(type as OptionType))
continue;
if (type === "choice") {
if (!Array.isArray(entry.choices) || entry.choices.length === 0) continue;
const choices: SchemaChoice[] = [];
const seen = new Set<string>();
for (const c of entry.choices) {
if (!isObject(c) || typeof c.value !== "string" || seen.has(c.value))
continue;
seen.add(c.value);
choices.push({
value: c.value,
label: typeof c.label === "string" ? c.label : c.value,
});
}
if (choices.length === 0) continue;
options.push({
id,
title,
type: "choice",
arg,
description:
typeof entry.description === "string" ? entry.description : undefined,
group: typeof entry.group === "string" ? entry.group : undefined,
default: typeof entry.default === "string" ? entry.default : undefined,
choices,
});
continue;
}
const option: SchemaOption = {
id,
title,
type: type as OptionType,
arg,
description:
typeof entry.description === "string" ? entry.description : undefined,
group: typeof entry.group === "string" ? entry.group : undefined,
};
if (entry.default !== undefined) option.default = entry.default;
if (isFiniteNumber(entry.min)) option.min = entry.min;
if (isFiniteNumber(entry.max)) option.max = entry.max;
if (isFiniteNumber(entry.step)) option.step = entry.step;
if (typeof entry.placeholder === "string")
option.placeholder = entry.placeholder;
options.push(option);
}
const groups: SchemaGroup[] = [];
if (Array.isArray(parsed.groups)) {
const seen = new Set<string>();
for (const g of parsed.groups) {
if (!isObject(g) || typeof g.id !== "string" || seen.has(g.id)) continue;
seen.add(g.id);
groups.push({
id: g.id,
title: typeof g.title === "string" ? g.title : g.id,
description:
typeof g.description === "string" ? g.description : undefined,
});
}
}
const dependencies: SchemaDependency[] = [];
if (Array.isArray(parsed.dependencies)) {
for (const d of parsed.dependencies) {
if (!isObject(d) || typeof d.target !== "string") continue;
if (!isCondition(d.when)) continue;
dependencies.push({
target: d.target,
when: d.when,
effect:
d.effect === "hide" || d.effect === "disable" ? d.effect : "disable",
});
}
}
if (options.length === 0) return null;
return {
$schema: parsed.$schema!.toString(),
schemaVersion: 1,
meta: isObject(parsed.meta) ? parsed.meta : undefined,
groups,
options,
dependencies,
};
}
export function defaultValues(schema: ArgsSchema): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const option of schema.options) {
values[option.id] = optionDefault(option);
}
return values;
}
export function optionDefault(option: SchemaOption): unknown {
switch (option.type) {
case "boolean":
return typeof option.default === "boolean" ? option.default : false;
case "int":
case "number":
return isFiniteNumber(option.default) ? option.default : 0;
case "string":
return typeof option.default === "string" ? option.default : "";
case "choice": {
if (typeof option.default === "string") {
const match = option.choices?.find((c) => c.value === option.default);
if (match) return match.value;
}
return option.choices?.[0]?.value ?? "";
}
}
}
export function sanitizeValue(option: SchemaOption, value: unknown): unknown {
switch (option.type) {
case "boolean":
return typeof value === "boolean" ? value : optionDefault(option);
case "int":
return isFiniteNumber(value) ? Math.trunc(value) : optionDefault(option);
case "number":
return isFiniteNumber(value) ? value : optionDefault(option);
case "string":
return typeof value === "string" ? value : optionDefault(option);
case "choice": {
if (
typeof value === "string" &&
option.choices?.some((c) => c.value === value)
) {
return value;
}
return optionDefault(option);
}
}
}
export function mergeValues(
schema: ArgsSchema,
saved: Record<string, unknown> | undefined,
): Record<string, unknown> {
const values = defaultValues(schema);
if (!saved) return values;
for (const option of schema.options) {
if (saved[option.id] !== undefined) {
values[option.id] = sanitizeValue(option, saved[option.id]);
}
}
return values;
}
function evaluateLeaf(
condition: Record<string, unknown>,
values: Record<string, unknown>,
): boolean {
const optionId = typeof condition.option === "string" ? condition.option : "";
const value = optionId in values ? values[optionId] : undefined;
if (condition.equals !== undefined) return value === condition.equals;
if (Array.isArray(condition.in)) {
return condition.in.some((candidate) => candidate === value);
}
if (condition.not !== undefined) return value !== condition.not;
if (condition.exists !== undefined) {
return condition.exists
? value !== undefined && value !== null && value !== "" && value !== false
: value === undefined ||
value === null ||
value === "" ||
value === false;
}
return false;
}
export function evaluateCondition(
condition: Condition,
values: Record<string, unknown>,
): boolean {
if ("option" in condition) return evaluateLeaf(condition, values);
if ("all" in condition)
return condition.all.every((c) => evaluateCondition(c, values));
if ("any" in condition)
return condition.any.some((c) => evaluateCondition(c, values));
if ("not" in condition) return !evaluateCondition(condition.not, values);
return false;
}
export function computeEffects(
schema: ArgsSchema,
values: Record<string, unknown>,
): Record<string, OptionEffects> {
const effects: Record<string, OptionEffects> = {};
for (const option of schema.options) {
effects[option.id] = { hidden: false, disabled: false };
}
for (const dep of schema.dependencies) {
if (!(dep.target in effects)) continue;
if (!evaluateCondition(dep.when, values)) continue;
const effect = effects[dep.target];
if (dep.effect === "hide") {
effect.hidden = true;
effect.disabled = true;
} else {
effect.disabled = true;
}
}
return effects;
}
export function buildArgs(
schema: ArgsSchema,
values: Record<string, unknown>,
effects: Record<string, OptionEffects>,
): string[] {
const args: string[] = [];
for (const option of schema.options) {
const effect = effects[option.id];
if (!effect) continue;
if (effect.hidden || effect.disabled) continue;
const value = values[option.id];
switch (option.type) {
case "boolean":
if (value === true) args.push(option.arg);
break;
case "int":
case "number":
if (isFiniteNumber(value)) args.push(option.arg, String(value));
break;
case "string":
if (typeof value === "string" && value.trim() !== "") {
args.push(option.arg, value);
}
break;
case "choice":
if (typeof value === "string" && value !== "") {
args.push(option.arg, value);
}
break;
}
}
return args;
}
export function displayValue(option: SchemaOption, value: unknown): string {
switch (option.type) {
case "boolean":
return value === true ? "true" : "false";
case "int":
case "number":
return isFiniteNumber(value) ? String(value) : "";
case "string":
return typeof value === "string" ? value : "";
case "choice": {
if (typeof value !== "string") return "";
return option.choices?.find((c) => c.value === value)?.label ?? value;
}
}
}