mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-24 17:11:00 +00:00
Merge diamond into main (#175)
Co-authored-by: str1k3r <[email protected]>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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
|
||||
|
||||
+514
-363
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!",
|
||||
];
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user