-
-
-
- Welcome, {username}!
-
-
-
- {hasInstalledInstance ? (
- <>
-
{
- playSfx('click.wav');
- setSelectedInstance(e.target.value);
- }}
- className="w-full legacy-select p-3 text-2xl outline-none"
- >
- {installedStatus.vanilla_tu19 && (
- Vanilla Nightly (TU19)
- )}
- {installedStatus.vanilla_tu24 && (
- Vanilla TU24
- )}
-
-
- {installingInstance ? "WAITING..." : isRunning ? "RUNNING..." : "PLAY"}
-
- >
- ) : (
-
-
Game not installed
-
+ {buttons.map((btn: any, i: number) => (
+ isFocusedSection && setMenuFocus(i)}
+ onMouseLeave={() => setMenuFocus(null)}
+ onClick={() => {
+ if (isFocusedSection) {
+ playClickSound();
+ btn.action();
+ }
+ }}
+ className={`w-full h-12 flex items-center justify-center text-2xl mc-text-shadow transition-colors outline-none border-none ${menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
+ style={{
+ backgroundImage:
+ menuFocus === i
+ ? "url('/images/button_highlighted.png')"
+ : "url('/images/Button_Background.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+ {btn.label}
+
+ ))}
+ {!legacyMode && (
+
-
+
+
{
+ if (isFocusedSection) {
+ playSfx("orb.ogg");
+ setShowCredits(true);
+ }
+ }}
+ className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "cursor-default pointer-events-none" : ""}`}
+ >
+ EMERALD TEAM
+
+
{
+ if (isFocusedSection) {
+ playSfx("orb.ogg");
+ setShowSpecialThanks(true);
+ }
+ }}
+ className={`text-white/60 hover:text-[#FFFF55] text-xs mc-text-shadow tracking-[0.2em] transition-colors bg-transparent border-none outline-none ${!isFocusedSection ? "cursor-default pointer-events-none" : ""}`}
+ >
+ SPECIAL THANKS
+
+
+ )}
+
);
-};
+});
+
+export default HomeView;
diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx
index 4cc95d0..86e49cf 100644
--- a/src/components/views/SettingsView.tsx
+++ b/src/components/views/SettingsView.tsx
@@ -1,177 +1,572 @@
-import React from 'react';
-import { Icons } from '../Icons';
-import { TauriService } from '../../services/tauri';
-import { Runner } from '../../types';
-import { openUrl } from "@tauri-apps/plugin-opener";
+import { useState, useEffect, useRef, useMemo, memo } from "react";
+import { motion } from "framer-motion";
+import { TauriService, Runner } from "../../services/TauriService";
+import { usePlatform } from "../../hooks/usePlatform";
+import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
-interface SettingsViewProps {
- username: string;
- setUsername: (name: string) => void;
- isLinux: boolean;
- selectedRunner: string;
- setSelectedRunner: (runner: string) => void;
- availableRunners: Runner[];
- musicVol: number;
- setMusicVol: (vol: number) => void;
- sfxVol: number;
- setSfxVol: (vol: number) => void;
- isMuted: boolean;
- setIsMuted: (muted: boolean) => void;
- playSfx: (name: string, multiplier?: number) => void;
-}
+const SettingsView = memo(function SettingsView() {
+ const { setActiveView } = useUI();
+ const { vfxEnabled, setVfxEnabled, animationsEnabled, setAnimationsEnabled, musicVol: musicVolume, setMusicVol: setMusicVolume, sfxVol: sfxVolume, setSfxVol: setSfxVolume, layout, setLayout, linuxRunner, setLinuxRunner, perfBoost, setPerfBoost, rpcEnabled, setRpcEnabled, legacyMode, setLegacyMode, keepLauncherOpen, setKeepLauncherOpen, enableTrayIcon, setEnableTrayIcon } = useConfig();
+ const { currentTrack, setCurrentTrack, tracks, playClickSound, playBackSound } = useAudio();
+ const { isGameRunning, stopGame, isRunnerDownloading, runnerDownloadProgress, downloadRunner } = useGame();
+ const { isLinux, isMac } = usePlatform();
+ const [focusIndex, setFocusIndex] = useState
(null);
+ const [currentSubMenu, setCurrentSubMenu] = useState<"main" | "audio" | "video" | "controls" | "launcher">("main");
+ const [runners, setRunners] = useState([]);
+ const containerRef = useRef(null);
-export const SettingsView: React.FC = ({
- username,
- setUsername,
- isLinux,
- selectedRunner,
- setSelectedRunner,
- availableRunners,
- musicVol,
- setMusicVol,
- sfxVol,
- setSfxVol,
- isMuted,
- setIsMuted,
- playSfx,
-}) => {
- return (
-
-
Settings
-
-
-
In-game Username
-
- setUsername(e.target.value)}
- className="flex-1 bg-black border-4 border-slate-700 p-4 text-3xl outline-none focus:border-emerald-500"
- />
- {
- playSfx('wood click.wav');
- TauriService.saveConfig({
- username,
- linuxRunner: selectedRunner || undefined,
- });
- }}
- className="legacy-btn px-8 text-2xl relative"
- >
- Save
-
-
-
+ const layouts = ["KBM", "PLAYSTATION", "XBOX"];
- {isLinux && (
-
-
- Linux Runner
-
-
-
{
- playSfx('click.wav');
- setSelectedRunner(e.target.value);
- }}
- className="w-full legacy-select p-4 text-2xl outline-none focus:border-emerald-500"
- >
- Select a runner...
- {availableRunners.map((r) => (
-
- {r.name} ({r.type})
-
- ))}
-
- {availableRunners.length === 0 && (
-
- No Proton or Wine installations found. Please install Steam or Wine.
-
- )}
-
-
- )}
+ useEffect(() => {
+ TauriService.getAvailableRunners().then(setRunners);
+ }, [isRunnerDownloading]);
-
-
- Audio Controls
-
-
-
{
- setIsMuted(!isMuted);
- playSfx('pop.wav');
- }}
- className="legacy-btn mt-4 py-2"
- >
- {isMuted ? "UNMUTE ALL" : "MUTE ALL"}
-
-
+ const handleLayoutToggle = () => {
+ playClickSound();
+ const currentIndex = layouts.indexOf(layout);
+ const nextIndex = (currentIndex + 1) % layouts.length;
+ setLayout(layouts[nextIndex]);
+ };
-
-
- About the project
-
-
- I'm KayJann , and I absolutely love this project! It's my very first one,
- and my goal is to create a central hub for the LCE community to bring us all together.
-
-
Social Links
-
- openUrl("https://discord.gg/nzbxB8Hxjh")}
- className="social-btn btn-discord"
- title="Discord"
- >
-
-
- openUrl("https://github.com/KayJannOnGit")}
- className="social-btn btn-github"
- title="GitHub"
- >
-
-
- openUrl("https://reddit.com/user/KayJann")}
- className="social-btn btn-reddit"
- title="Reddit"
- >
-
-
-
+ const handleVfxToggle = () => {
+ playClickSound();
+ setVfxEnabled(!vfxEnabled);
+ };
+
+ const handleAnimationsToggle = () => {
+ playClickSound();
+ setAnimationsEnabled(!animationsEnabled);
+ };
+
+ const handlePerfToggle = () => {
+ playClickSound();
+ setPerfBoost(!perfBoost);
+ };
+
+ const handleRpcToggle = () => {
+ playClickSound();
+ setRpcEnabled(!rpcEnabled);
+ };
+
+ const handleLegacyToggle = () => {
+ playClickSound();
+ setLegacyMode(!legacyMode);
+ };
+
+ const handleKeepOpenToggle = () => {
+ playClickSound();
+ setKeepLauncherOpen(!keepLauncherOpen);
+ };
+
+ const handleTrayToggle = () => {
+ playClickSound();
+ setEnableTrayIcon(!enableTrayIcon);
+ };
+
+ const handleRunnerToggle = () => {
+ playClickSound();
+ if (runners.length === 0) return;
+ const currentIndex = runners.findIndex((r) => r.id === linuxRunner);
+ const nextIndex = (currentIndex + 1) % runners.length;
+ setLinuxRunner(runners[nextIndex].id);
+ };
+
+ const handleTrackToggle = () => {
+ playClickSound();
+ setCurrentTrack((currentTrack + 1) % tracks.length);
+ };
+
+ const handleResetSetup = () => {
+ playClickSound();
+
+ // Create styled confirmation dialog
+ const dialog = document.createElement('div');
+ dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
+ dialog.innerHTML = `
+
+
Reset Setup
+
Are you sure you want to reset launcher setup?
+
+ Yes
+ No
-
+ `;
+
+ document.body.appendChild(dialog);
+
+ const handleYes = () => {
+ document.body.removeChild(dialog);
+ showSecondConfirmation();
+ };
+
+ const handleNo = () => {
+ document.body.removeChild(dialog);
+ };
+
+ dialog.querySelector('#reset-yes')?.addEventListener('click', handleYes);
+ dialog.querySelector('#reset-no')?.addEventListener('click', handleNo);
+
+ dialog.addEventListener('click', (e) => {
+ if (e.target === dialog) {
+ document.body.removeChild(dialog);
+ }
+ });
+ };
+
+ const showSecondConfirmation = () => {
+ const dialog = document.createElement('div');
+ dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
+ dialog.innerHTML = `
+
+
CONFIRM RESET
+
+
⚠️ This will:
+
+ Clear all launcher settings
+ Reset your username
+ Show setup screen again
+ Require reconfiguration
+
+
This action cannot be undone!
+
+
+ YES, RESET
+ Cancel
+
+
+ `;
+
+ document.body.appendChild(dialog);
+
+ const handleFinalYes = () => {
+ document.body.removeChild(dialog);
+ performReset();
+ };
+
+ const handleFinalNo = () => {
+ document.body.removeChild(dialog);
+ };
+
+ dialog.querySelector('#reset-final-yes')?.addEventListener('click', handleFinalYes);
+ dialog.querySelector('#reset-final-no')?.addEventListener('click', handleFinalNo);
+
+ dialog.addEventListener('click', (e) => {
+ if (e.target === dialog) {
+ document.body.removeChild(dialog);
+ }
+ });
+ };
+
+ const performReset = () => {
+ // Clear all localStorage data
+ localStorage.clear();
+
+ // Set setup as not completed
+ localStorage.setItem('lce-setup-completed', 'false');
+
+ // Force reload to show setup screen
+ window.location.reload();
+ };
+
+ let trackName = "Unknown";
+ if (tracks && tracks.length > 0) {
+ const fullPath = tracks[currentTrack];
+ if (fullPath) {
+ trackName = fullPath
+ .split("/")
+ .pop()
+ ?.replace(".ogg", "")
+ .replace(".wav", "") || "Unknown";
+ }
+ }
+
+ const selectedRunnerName =
+ runners.find((r) => r.id === linuxRunner)?.name || "Native / Default";
+
+ type SettingsItem =
+ | {
+ id: string;
+ label: string;
+ type: "slider";
+ value: number;
+ onChange: (val: any) => void;
+ }
+ | {
+ id: string;
+ label: string;
+ type: "button";
+ onClick: () => void;
+ small?: boolean;
+ color?: string;
+ };
+
+ const settingsItems = useMemo
(() => {
+ const items: SettingsItem[] = [];
+
+ if (currentSubMenu === "main") {
+ items.push({
+ id: "audio_menu",
+ label: "Audio",
+ type: "button",
+ onClick: () => { playClickSound(); setCurrentSubMenu("audio"); setFocusIndex(0); },
+ });
+ items.push({
+ id: "video_menu",
+ label: "User Interface",
+ type: "button",
+ onClick: () => { playClickSound(); setCurrentSubMenu("video"); setFocusIndex(0); },
+ });
+ items.push({
+ id: "controls_menu",
+ label: "Controls",
+ type: "button",
+ onClick: () => { playClickSound(); setCurrentSubMenu("controls"); setFocusIndex(0); },
+ });
+ items.push({
+ id: "launcher_menu",
+ label: "Options",
+ type: "button",
+ onClick: () => { playClickSound(); setCurrentSubMenu("launcher"); setFocusIndex(0); },
+ });
+ } else if (currentSubMenu === "audio") {
+ items.push({
+ id: "music",
+ label: `Music: ${musicVolume ?? 50}%`,
+ type: "slider",
+ value: musicVolume ?? 50,
+ onChange: setMusicVolume,
+ });
+ items.push({
+ id: "sfx",
+ label: `SFX: ${sfxVolume ?? 100}%`,
+ type: "slider",
+ value: sfxVolume ?? 100,
+ onChange: setSfxVolume,
+ });
+ items.push({
+ id: "track",
+ label: `${trackName} - C418`,
+ type: "button",
+ onClick: handleTrackToggle,
+ });
+ } else if (currentSubMenu === "video") {
+ items.push({
+ id: "vfx",
+ label: `VFX: ${vfxEnabled ? "ON" : "OFF"}`,
+ type: "button",
+ onClick: handleVfxToggle,
+ });
+ items.push({
+ id: "animations",
+ label: `Animations: ${animationsEnabled ? "ON" : "OFF"}`,
+ type: "button",
+ onClick: handleAnimationsToggle,
+ });
+ if (isMac) {
+ items.push({
+ id: "perf",
+ label: `M1/M2 Boost: ${perfBoost ? "Enabled" : "Disabled"}`,
+ type: "button",
+ onClick: handlePerfToggle,
+ });
+ }
+ } else if (currentSubMenu === "controls") {
+ items.push({
+ id: "layout",
+ label: `Layout: ${layout}`,
+ type: "button",
+ onClick: handleLayoutToggle,
+ });
+ } else if (currentSubMenu === "launcher") {
+ items.push({
+ id: "rpc",
+ label: `Discord RPC: ${rpcEnabled ? "ON" : "OFF"}`,
+ type: "button",
+ onClick: handleRpcToggle,
+ });
+ items.push({
+ id: "legacy",
+ label: `Legacy Mode: ${legacyMode ? "ON" : "OFF"}`,
+ type: "button",
+ onClick: handleLegacyToggle,
+ });
+ items.push({
+ id: "keep_open",
+ label: `Keep Launcher Open: ${keepLauncherOpen ? "ON" : "OFF"}`,
+ type: "button",
+ onClick: handleKeepOpenToggle,
+ });
+ items.push({
+ id: "tray_icon",
+ label: `Tray Icon: ${enableTrayIcon ? "ON" : "OFF"}`,
+ type: "button",
+ onClick: handleTrayToggle,
+ });
+
+ if (isLinux) {
+ items.push({
+ id: "runner",
+ label: `Runner: ${selectedRunnerName}`,
+ type: "button",
+ onClick: handleRunnerToggle,
+ });
+
+ if (runners.length === 0 || runners.every(r => r.type !== 'proton')) {
+ items.push({
+ id: "download_runner",
+ label: isRunnerDownloading
+ ? `Downloading Runner... ${Math.floor(runnerDownloadProgress || 0)}%`
+ : "Download GE-Proton (Recommended)",
+ type: "button",
+ onClick: () => {
+ if (!isRunnerDownloading) {
+ downloadRunner("GE-Proton9-25", "https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz");
+ }
+ },
+ small: true,
+ });
+ }
+ }
+
+ items.push({
+ id: "reset_setup",
+ label: "Reset Setup",
+ type: "button",
+ onClick: handleResetSetup,
+ color: "orange",
+ small: true,
+ });
+ }
+
+ if (isGameRunning) {
+ items.push({
+ id: "stop",
+ label: "STOP GAME",
+ type: "button",
+ onClick: stopGame,
+ color: "red",
+ });
+ }
+
+ items.push({
+ id: "back",
+ label: currentSubMenu === "main" ? "Done" : "Back",
+ type: "button",
+ onClick: () => {
+ playBackSound();
+ if (currentSubMenu === "main") {
+ setActiveView("main");
+ } else {
+ setCurrentSubMenu("main");
+ setFocusIndex(0);
+ }
+ },
+ });
+
+ return items;
+ }, [
+ currentSubMenu,
+ musicVolume,
+ sfxVolume,
+ trackName,
+ vfxEnabled,
+ rpcEnabled,
+ legacyMode,
+ animationsEnabled,
+ keepLauncherOpen,
+ enableTrayIcon,
+ layout,
+ isLinux,
+ selectedRunnerName,
+ isRunnerDownloading,
+ runnerDownloadProgress,
+ isMac,
+ perfBoost,
+ isGameRunning,
+ handleTrackToggle,
+ handleVfxToggle,
+ handleRpcToggle,
+ handleLegacyToggle,
+ handleAnimationsToggle,
+ handleKeepOpenToggle,
+ handleTrayToggle,
+ handleLayoutToggle,
+ handleRunnerToggle,
+ handlePerfToggle,
+ handleResetSetup,
+ stopGame,
+ downloadRunner,
+ playClickSound,
+ playBackSound,
+ setActiveView,
+ runners,
+ ]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" || e.key === "Backspace") {
+ playBackSound();
+ if (currentSubMenu !== "main") {
+ setCurrentSubMenu("main");
+ setFocusIndex(0);
+ } else {
+ setActiveView("main");
+ }
+ return;
+ }
+
+ const itemCount = settingsItems.length;
+
+ if (e.key === "ArrowDown") {
+ setFocusIndex((prev) =>
+ prev === null || prev >= itemCount - 1 ? 0 : prev + 1,
+ );
+ } else if (e.key === "ArrowUp") {
+ setFocusIndex((prev) =>
+ prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
+ );
+ } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
+ if (focusIndex === null) return;
+ const item = settingsItems[focusIndex];
+ if (item.type === "slider") {
+ const delta = e.key === "ArrowRight" ? 5 : -5;
+ item.onChange((v: number) => Math.max(0, Math.min(100, v + delta)));
+ }
+ } else if (e.key === "Enter" && focusIndex !== null) {
+ const item = settingsItems[focusIndex];
+ if (item.type === "button") {
+ item.onClick();
+ }
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [focusIndex, settingsItems, playBackSound, setActiveView, currentSubMenu]);
+
+ useEffect(() => {
+ if (focusIndex !== null) {
+ const el = containerRef.current?.querySelector(
+ `[data-index="${focusIndex}"]`,
+ ) as HTMLElement;
+ if (el) el.focus();
+ }
+ }, [focusIndex]);
+
+ const getItemStyle = (index: number) => ({
+ backgroundImage:
+ focusIndex === index
+ ? "url('/images/button_highlighted.png')"
+ : "url('/images/Button_Background.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated" as const,
+ });
+
+ const getSliderStyle = (index: number) => ({
+ backgroundImage: "url('/images/Button_Background2.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated" as const,
+ color: focusIndex === index ? "#FFFF55" : "white",
+ });
+
+ return (
+
+
+ {currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" ? "Audio" : currentSubMenu === "video" ? "User Interface" : currentSubMenu === "controls" ? "Controls" : "Options"}
+
+
+
+ {settingsItems.map((item, index) => {
+ if (item.id === "back") return null;
+
+ if (item.type === "slider") {
+ return (
+
setFocusIndex(index)}
+ className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
+ style={getSliderStyle(index)}
+ >
+
+ {item.label}
+
+
+ item.onChange(parseInt(e.target.value))}
+ onMouseUp={playClickSound}
+ className="mc-slider-custom w-[calc(100%+16px)] h-full opacity-100 cursor-pointer z-20 outline-none m-0"
+ />
+
+
+ );
+ }
+
+ const isRed = (item as any).color === "red";
+ const isSmall = (item as any).small;
+
+ return (
+
setFocusIndex(index)}
+ onClick={item.onClick}
+ className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${isRed
+ ? focusIndex === index
+ ? "text-red-400"
+ : "text-red-200"
+ : focusIndex === index
+ ? "text-[#FFFF55]"
+ : "text-white"
+ } ${isRed ? "hover:text-red-500" : "hover:text-[#FFFF55]"}`}
+ style={getItemStyle(index)}
+ >
+ 20 ? "text-lg" : "text-xl"} truncate w-full text-center`}
+ >
+ {item.label}
+
+
+ );
+ })}
+
+
+ {(() => {
+ const backIndex = settingsItems.findIndex((i) => i.id === "back");
+ const backItem = settingsItems[backIndex];
+ if (!backItem || backItem.type !== "button") return null;
+
+ return (
+ setFocusIndex(backIndex)}
+ onClick={backItem.onClick}
+ className={`w-72 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === backIndex ? "text-[#FFFF55]" : "text-white"
+ }`}
+ style={getItemStyle(backIndex)}
+ >
+ Back
+
+ );
+ })()}
+
);
-};
+});
+
+export default SettingsView;
diff --git a/src/components/views/SetupView.tsx b/src/components/views/SetupView.tsx
new file mode 100644
index 0000000..8e3a201
--- /dev/null
+++ b/src/components/views/SetupView.tsx
@@ -0,0 +1,626 @@
+import { useState, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { TauriService, Runner } from "../../services/TauriService";
+import { usePlatform } from "../../hooks/usePlatform";
+import { useConfig, useAudio, useGame } from "../../context/LauncherContext";
+
+interface SetupViewProps {
+ onComplete: () => void;
+}
+
+const SetupView: React.FC = ({ onComplete }) => {
+ const { isLinux, isMac } = usePlatform();
+ const {
+ username, setUsername,
+ setHasCompletedSetup,
+ profile,
+ setEnableTrayIcon: setConfigTray,
+ setVfxEnabled: setConfigVfx,
+ setRpcEnabled: setConfigRpc,
+ setKeepLauncherOpen: setConfigKeepOpen,
+ setLinuxRunner,
+ linuxRunner: configLinuxRunner,
+ vfxEnabled: configVfx,
+ enableTrayIcon: configTray,
+ rpcEnabled: configRpc,
+ keepLauncherOpen: configKeepOpen
+ } = useConfig();
+ const { playClickSound, playSfx } = useAudio();
+
+ const { editions } = useGame();
+ const titleImage = editions.find(e => e.id === profile)?.titleImage || "/images/MenuTitle.png";
+
+ const [currentStep, setCurrentStep] = useState(0);
+ const [focusIndex, setFocusIndex] = useState(0);
+ const [tempUsername, setTempUsername] = useState(username);
+ const [runners, setRunners] = useState([]);
+ const [selectedRunner, setSelectedRunner] = useState("");
+ const [isSettingUpRuntime, setIsSettingUpRuntime] = useState(false);
+ const [setupProgress, setSetupProgress] = useState<{ stage: string; message: string; percent?: number } | null>(null);
+ const [runtimeAlreadyInstalled, setRuntimeAlreadyInstalled] = useState(false);
+
+ const [enableTrayIcon, setEnableTrayIcon] = useState(configTray);
+ const [enableVfx, setEnableVfx] = useState(configVfx);
+ const [enableDiscordRPC, setEnableDiscordRPC] = useState(configRpc);
+ const [keepLauncherOpen, setKeepLauncherOpen] = useState(configKeepOpen);
+
+ const totalSteps = isLinux ? 4 : 4;
+
+ useEffect(() => {
+ if (isLinux || isMac) {
+ TauriService.getAvailableRunners().then(availableRunners => {
+ setRunners(availableRunners);
+ if (configLinuxRunner && availableRunners.find(r => r.id === configLinuxRunner)) {
+ setSelectedRunner(configLinuxRunner);
+ }
+ });
+ }
+
+ if (isMac) {
+ checkMacOSRuntime();
+
+ const unlisten = TauriService.onMacosProgress((progress) => {
+ console.log("[macOS Setup Progress]", progress);
+ setSetupProgress(progress);
+ });
+
+ return () => {
+ unlisten.then(f => f?.());
+ };
+ }
+ }, [isLinux, isMac]);
+
+ const checkMacOSRuntime = async () => {
+ try {
+ const localStorageInstalled = localStorage.getItem('lce-macos-runtime-installed') === 'true';
+
+ if (localStorageInstalled) {
+ console.log("[macOS Runtime] Using cached installation status");
+ try {
+ const runtimeCheck = await TauriService.checkMacOSRuntimeInstalledFast();
+ if (runtimeCheck) {
+ setRuntimeAlreadyInstalled(true);
+ return;
+ } else {
+ console.log("[macOS Runtime] Cache was wrong, clearing");
+ localStorage.removeItem('lce-macos-runtime-installed');
+ setRuntimeAlreadyInstalled(false);
+ return;
+ }
+ } catch (error) {
+ console.log("[macOS Runtime] Fast check failed, using cache");
+ setRuntimeAlreadyInstalled(true);
+ return;
+ }
+ } else {
+ console.log("[macOS Runtime] No installation detected");
+ setRuntimeAlreadyInstalled(false);
+ }
+ } catch (error) {
+ console.error("[macOS Runtime] Error checking:", error);
+ setRuntimeAlreadyInstalled(false);
+ }
+ };
+
+ const handleRunnerSelect = (runnerId: string) => {
+ playClickSound();
+ setSelectedRunner(runnerId);
+ };
+
+ const handleNext = async () => {
+ playClickSound();
+
+ if (currentStep === 0) {
+ setUsername(tempUsername);
+ setCurrentStep(1);
+ setFocusIndex(0);
+ } else if (currentStep === 1) {
+ if (isLinux && selectedRunner) {
+ setLinuxRunner(selectedRunner);
+ }
+ setCurrentStep(2);
+ setFocusIndex(0);
+ } else if (currentStep === 2) {
+ setConfigTray(enableTrayIcon);
+ setConfigVfx(enableVfx);
+ setConfigRpc(enableDiscordRPC);
+ setConfigKeepOpen(keepLauncherOpen);
+
+ setCurrentStep(3);
+ setFocusIndex(0);
+ } else if (currentStep === 3) {
+ playSfx("levelup.ogg");
+ setHasCompletedSetup(true);
+ onComplete();
+ }
+ };
+
+ const handleBack = () => {
+ playClickSound();
+ if (currentStep > 0) {
+ setCurrentStep(currentStep - 1);
+ setFocusIndex(0);
+ }
+ };
+
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => {
+ // Elements count per step
+ let count = 0;
+ if (currentStep === 0) count = 2; // Input, Next
+ else if (currentStep === 1) {
+ if (isLinux) count = runners.length + 2; // Runners, Back, Next
+ else if (isMac) count = 3; // Install, Back, Next
+ else count = 2; // Back, Next
+ } else if (currentStep === 2) count = 6; // 4 Toggles, Back, Next
+ else if (currentStep === 3) count = 2; // Back, Finish
+
+ if (e.key === "ArrowDown" || e.key === "Tab") {
+ e.preventDefault();
+ setFocusIndex((prev) => (prev + 1) % count);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setFocusIndex((prev) => (prev - 1 + count) % count);
+ } else if (e.key === "Enter") {
+ // Handle enter based on focusIndex and step
+ if (currentStep === 0) {
+ if (focusIndex === 0) handleNext(); // For input field
+ else if (focusIndex === 1) handleNext(); // Next button
+ } else if (currentStep === 1) {
+ if (isLinux) {
+ if (focusIndex < runners.length) handleRunnerSelect(runners[focusIndex].id);
+ else if (focusIndex === runners.length) handleBack();
+ else if (focusIndex === runners.length + 1) handleNext();
+ } else if (isMac) {
+ if (focusIndex === 0) handleMacosSetup();
+ else if (focusIndex === 1) handleBack();
+ else if (focusIndex === 2) handleNext();
+ } else {
+ if (focusIndex === 0) handleBack();
+ else if (focusIndex === 1) handleNext();
+ }
+ } else if (currentStep === 2) {
+ if (focusIndex === 0) { setEnableTrayIcon(!enableTrayIcon); playClickSound(); }
+ else if (focusIndex === 1) { setEnableVfx(!enableVfx); playClickSound(); }
+ else if (focusIndex === 2) { setEnableDiscordRPC(!enableDiscordRPC); playClickSound(); }
+ else if (focusIndex === 3) { setKeepLauncherOpen(!keepLauncherOpen); playClickSound(); }
+ else if (focusIndex === 4) handleBack();
+ else if (focusIndex === 5) handleNext();
+ } else if (currentStep === 3) {
+ if (focusIndex === 0) handleBack();
+ else if (focusIndex === 1) handleNext();
+ }
+ }
+ };
+ window.addEventListener("keydown", handleKey);
+ return () => window.removeEventListener("keydown", handleKey);
+ }, [currentStep, focusIndex, runners, enableTrayIcon, enableVfx, enableDiscordRPC, keepLauncherOpen, isLinux, isMac, tempUsername]);
+
+ const handleMacosSetup = async () => {
+ playClickSound();
+ setIsSettingUpRuntime(true);
+ setSetupProgress({ stage: "preparing", message: "Preparing macOS runtime setup...", percent: 0 });
+
+ try {
+ console.log("[macOS Setup] Starting runtime installation...");
+ await TauriService.setupMacosRuntime();
+ console.log("[macOS Setup] Runtime installation completed successfully!");
+ setSetupProgress({ stage: "completed", message: "Setup completed successfully!", percent: 100 });
+
+ localStorage.setItem('lce-macos-runtime-installed', 'true');
+ setRuntimeAlreadyInstalled(true);
+
+ setTimeout(() => {
+ setCurrentStep(2);
+ setIsSettingUpRuntime(false);
+ setSetupProgress(null);
+ }, 2000);
+ } catch (e) {
+ console.error("[macOS Setup] Error:", e);
+ setSetupProgress({ stage: "error", message: `Setup failed: ${e}`, percent: 0 });
+ setIsSettingUpRuntime(false);
+ }
+ };
+
+ const canProceed = () => {
+ if (currentStep === 0) {
+ return tempUsername.trim().length > 0;
+ }
+ if (currentStep === 1 && isMac) {
+ return runtimeAlreadyInstalled;
+ }
+ return true;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: totalSteps }, (_, i) => (
+
+ ))}
+
+
+
+
+ {currentStep === 0 && (
+
+
+ Welcome to Emerald Legacy
+
+
Let's configure your launcher
+
+
+
+ Username
+ setTempUsername(e.target.value)}
+ onFocus={() => setFocusIndex(0)}
+ className={`w-full px-4 py-3 bg-black/50 border-2 font-bold focus:outline-none transition-colors ${focusIndex === 0 ? "border-yellow-400" : "border-white"}`}
+ placeholder="Enter your username"
+ maxLength={16}
+ autoFocus
+ />
+
+
+
+ )}
+
+ {currentStep === 1 && isMac && (
+
+
+ macOS Compatibility
+
+
+ {runtimeAlreadyInstalled
+ ? "Emerald Legacy compatibility runtime is already installed"
+ : "Emerald Legacy needs compatibility runtime for macOS"
+ }
+
+
+ {setupProgress && (
+
+
{setupProgress.stage.toUpperCase()}
+
{setupProgress.message}
+ {setupProgress.percent !== undefined && (
+
+ )}
+
+ )}
+
+
+
+
+ {runtimeAlreadyInstalled ? "✓ Runtime Detected" : "⚠ Runtime Not Detected"}
+
+
+ {runtimeAlreadyInstalled
+ ? "The compatibility runtime is properly installed and ready to use."
+ : "You must install the compatibility runtime before proceeding to the next step."
+ }
+
+
+
+
setFocusIndex(0)}
+ disabled={isSettingUpRuntime}
+ className={`px-6 py-3 text-white font-bold bg-green-600 hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 active:scale-95 border-4 ${focusIndex === 0 ? "border-yellow-400" : "border-green-400"}`}
+ style={{
+ fontFamily: "'Mojangles', monospace",
+ imageRendering: "pixelated",
+ textShadow: "2px 2px 0px rgba(0,0,0,0.8)",
+ boxShadow: "4px 4px 0px rgba(0,0,0,0.3)",
+ fontSize: "16px",
+ letterSpacing: "1px"
+ }}
+ >
+ {isSettingUpRuntime ? "Installing..." : runtimeAlreadyInstalled ? "Reinstall Runtime" : "Install Runtime"}
+
+
+ {!runtimeAlreadyInstalled && (
+
+ ⚠ Installation required before proceeding to next step
+
+ )}
+
+
+ )}
+
+ {currentStep === 1 && isLinux && (
+
+
+ Linux Compatibility
+
+
Choose your preferred compatibility layer
+
+ {runners.length === 0 ? (
+
+
No compatible runners found. Please install Wine or Proton.
+
+ ) : (
+
+ {runners.map((runner, idx) => (
+
handleRunnerSelect(runner.id)}
+ onMouseEnter={() => setFocusIndex(idx)}
+ className={`w-full p-4 text-left border-2 transition-all duration-200 ${selectedRunner === runner.id
+ ? "bg-white/20 border-white shadow-[0_0_15px_rgba(255,255,255,0.2)]"
+ : "bg-black/50 border-white/20"
+ } ${focusIndex === idx ? "border-yellow-400" : ""}`}
+ >
+ {runner.name}
+ {runner.type}
+
+ ))}
+
+ )}
+
+
You can change this later in settings
+
+ )}
+
+ {currentStep === 1 && !isMac && !isLinux && (
+
+
+ Windows Setup
+
+
Everything is ready to go!
+
✓ Native compatibility
+
+
+
✓ Windows Native Support
+
Emerald Legacy runs natively on Windows without additional requirements.
+
+
+ )}
+
+ {currentStep === 2 && (
+
+
+ Customize Your Experience
+
+
Choose your preferred launcher settings
+
+
+
+
+
+
System Tray Icon
+
Keep launcher accessible in system tray
+
+
{
+ playClickSound();
+ setEnableTrayIcon(!enableTrayIcon);
+ }}
+ onMouseEnter={() => setFocusIndex(0)}
+ className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 0 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
+ style={{ imageRendering: "pixelated" }}
+ >
+
+
+
+
+
+
+
+
+
Visual Effects
+
Click particles and animations
+
+
{
+ playClickSound();
+ setEnableVfx(!enableVfx);
+ }}
+ onMouseEnter={() => setFocusIndex(1)}
+ className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 1 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
+ style={{ imageRendering: "pixelated" }}
+ >
+
+
+
+
+
+
+
+
+
Discord Rich Presence
+
Show your Emerald Legacy status on Discord
+
+
{
+ playClickSound();
+ setEnableDiscordRPC(!enableDiscordRPC);
+ }}
+ onMouseEnter={() => setFocusIndex(2)}
+ className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 2 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
+ style={{ imageRendering: "pixelated" }}
+ >
+
+
+
+
+
+
+
+
+
Keep Launcher Open
+
Keep launcher running after game launch
+
+
{
+ playClickSound();
+ setKeepLauncherOpen(!keepLauncherOpen);
+ }}
+ onMouseEnter={() => setFocusIndex(3)}
+ className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 3 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
+ style={{ imageRendering: "pixelated" }}
+ >
+
+
+
+
+
+
+
You can change these later in settings
+
+ )}
+
+ {currentStep === 3 && (
+
+
+ Setup Complete!
+
+
+
+
+
Username: {tempUsername}
+ {isMac && (
+
+ Runtime: Ready
+
+ )}
+ {isLinux && selectedRunner && (
+
+ Runner: {runners.find(r => r.id === selectedRunner)?.name}
+
+ )}
+
+
Customization:
+
+ {enableTrayIcon && Tray Icon }
+ {enableVfx && Visual Effects }
+ {enableDiscordRPC && Discord RPC }
+ {keepLauncherOpen && Keep Open }
+
+
+
+
+
+
Emerald Legacy is now configured and ready to use!
+
+ )}
+
+
+
+
+
+ {currentStep > 0 && (
+ {
+ if (currentStep === 0) setFocusIndex(1);
+ else if (currentStep === 1) setFocusIndex(isLinux ? runners.length : (isMac ? 1 : 0));
+ else if (currentStep === 2) setFocusIndex(4);
+ else if (currentStep === 3) setFocusIndex(0);
+ }}
+ disabled={currentStep === 0}
+ className={`mc-setup-nav-btn px-6 py-3 text-white font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_10px_rgba(250,204,21,0.3)] ${(currentStep === 1 && ((isLinux && focusIndex === runners.length) || (isMac && focusIndex === 1) || (!isLinux && !isMac && focusIndex === 0))) ||
+ (currentStep === 2 && focusIndex === 4) ||
+ (currentStep === 3 && focusIndex === 0)
+ ? "border-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.3)]" : ""
+ }`}
+ >
+ Back
+
+ )}
+
+ {
+ if (currentStep === 0) setFocusIndex(1);
+ else if (currentStep === 1) setFocusIndex(isLinux ? runners.length + 1 : (isMac ? 2 : 1));
+ else if (currentStep === 2) setFocusIndex(5);
+ else if (currentStep === 3) setFocusIndex(1);
+ }}
+ disabled={!canProceed()}
+ className={`${currentStep > 0 ? '' : 'ml-auto'} mc-setup-nav-btn px-6 py-3 text-white font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_10px_rgba(250,204,21,0.3)] ${(currentStep === 0 && focusIndex === 1) ||
+ (currentStep === 1 && ((isLinux && focusIndex === runners.length + 1) || (isMac && focusIndex === 2) || (!isLinux && !isMac && focusIndex === 1))) ||
+ (currentStep === 2 && focusIndex === 5) ||
+ (currentStep === 3 && focusIndex === 1)
+ ? "border-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.3)]" : ""
+ }`}
+ >
+ {currentStep === totalSteps - 1 ? "Finish" : "Next"}
+
+
+
+
+
+
+
+ );
+};
+
+export default SetupView;
diff --git a/src/components/views/SkinsView.tsx b/src/components/views/SkinsView.tsx
new file mode 100644
index 0000000..3057285
--- /dev/null
+++ b/src/components/views/SkinsView.tsx
@@ -0,0 +1,404 @@
+import { useState, useEffect, useRef, memo } from 'react';
+import { motion } from 'framer-motion';
+import { useLocalStorage } from '../../hooks/useLocalStorage';
+import { TauriService } from '../../services/TauriService';
+import { useUI, useAudio, useSkin, useConfig } from '../../context/LauncherContext';
+
+interface SavedSkin {
+ id: string;
+ name: string;
+ url: string;
+}
+
+const DEFAULT_SKINS: SavedSkin[] = [
+ { id: 'default', name: 'Default Steve', url: '/images/Default.png' },
+ { id: 'journ3ym3n', name: 'Journ3ym3n', url: '/Skins/Journ3ym3n.png' },
+ { id: 'justneki', name: 'JustNeki', url: '/Skins/JustNeki.png' },
+ { id: 'kayjann', name: 'KayJann', url: '/Skins/KayJann.png' },
+ { id: 'leon', name: 'Leon', url: '/Skins/Leon.png' },
+ { id: 'mr_anilex', name: 'mr_anilex', url: '/Skins/mr_anilex.png' },
+ { id: 'neoapps', name: 'neoapps', url: '/Skins/neoapps.png' },
+ { id: 'peter', name: 'Peter', url: '/Skins/Peter.png' },
+];
+
+const SkinsView = memo(function SkinsView() {
+ const { setActiveView } = useUI();
+ const { playClickSound, playBackSound } = useAudio();
+ const { skinUrl, setSkinUrl } = useSkin();
+
+ const [focusIndex, setFocusIndex] = useState(null);
+ const containerRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ const [storedSkins, setStoredSkins] = useLocalStorage('lce-custom-skins', []);
+ const savedSkins = [...DEFAULT_SKINS, ...storedSkins.filter(s => !DEFAULT_SKINS.some(d => d.id === s.id))];
+
+ const TOP_BUTTONS_COUNT = 3; // Import, Delete, Folder
+ const SKINS_START_INDEX = TOP_BUTTONS_COUNT;
+ const BACK_BUTTON_INDEX = SKINS_START_INDEX + savedSkins.length;
+ const ITEM_COUNT = BACK_BUTTON_INDEX + 1;
+
+ const setSavedSkins = (newSkins: SavedSkin[] | ((val: SavedSkin[]) => SavedSkin[])) => {
+ const updatedSkins = typeof newSkins === 'function' ? newSkins(savedSkins) : newSkins;
+ const customOnes = updatedSkins.filter(s => !DEFAULT_SKINS.some(d => d.id === s.id));
+ setStoredSkins(customOnes);
+ };
+
+ const [activeSkinId, setActiveSkinId] = useState(null);
+
+ const [showImportModal, setShowImportModal] = useState(false);
+ const [modalFocusIndex, setModalFocusIndex] = useState(0);
+ const [importMode, setImportMode] = useState<'file' | 'username' | null>(null);
+ const [importUsername, setImportUsername] = useState('');
+ const [isImporting, setIsImporting] = useState(false);
+ const [importError, setImportError] = useState('');
+
+ const processSkinImage = (url: string, defaultName: string) => {
+ const img = new Image();
+ img.crossOrigin = "Anonymous";
+ img.onload = () => {
+ const cvs = document.createElement("canvas");
+ cvs.width = 64;
+ cvs.height = 32;
+ const ctx = cvs.getContext("2d");
+ if (ctx) {
+ ctx.drawImage(img, 0, 0, 64, 32, 0, 0, 64, 32);
+ const base64String = cvs.toDataURL("image/png");
+ const newId = Date.now().toString();
+ const newSkin = { id: newId, name: defaultName, url: base64String };
+ setSavedSkins(prev => [...prev, newSkin]);
+ setSkinUrl(base64String);
+ setActiveSkinId(newId);
+ }
+ };
+ img.src = url;
+ };
+
+ const handleFetchUsername = async () => {
+ if (!importUsername.trim()) return;
+ playClickSound();
+ setIsImporting(true);
+ setImportError('');
+ try {
+ const [base64Raw, exactName] = await TauriService.fetchSkin(importUsername.trim());
+ const skinBase64 = `data:image/png;base64,${base64Raw}`;
+ processSkinImage(skinBase64, exactName.substring(0, 16));
+
+ setShowImportModal(false);
+ setImportMode(null);
+ setImportUsername('');
+ } catch (e: any) {
+ setImportError(typeof e === 'string' ? e : (e.message || 'Failed to fetch skin'));
+ } finally {
+ setIsImporting(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!activeSkinId) {
+ const match = savedSkins.find(s => s.url === skinUrl);
+ if (match) setActiveSkinId(match.id);
+ }
+ }, [activeSkinId, savedSkins, skinUrl]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (showImportModal) {
+ if (e.key === 'Escape') {
+ playBackSound();
+ if (importMode) {
+ setImportMode(null);
+ setImportUsername('');
+ setImportError('');
+ setModalFocusIndex(0);
+ } else {
+ setShowImportModal(false);
+ setModalFocusIndex(0);
+ }
+ } else if (e.key === 'ArrowDown' || e.key === 'Tab') {
+ e.preventDefault();
+ setModalFocusIndex(prev => (prev + 1) % 3);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setModalFocusIndex(prev => (prev - 1 + 3) % 3);
+ } else if (e.key === 'Enter') {
+ if (!importMode) {
+ if (modalFocusIndex === 0) { playClickSound(); fileInputRef.current?.click(); }
+ else if (modalFocusIndex === 1) { playClickSound(); setImportMode('username'); setModalFocusIndex(0); }
+ else if (modalFocusIndex === 2) {
+ playBackSound();
+ setShowImportModal(false);
+ setModalFocusIndex(0);
+ }
+ } else {
+ if (modalFocusIndex === 0 || modalFocusIndex === 1) handleFetchUsername();
+ else if (modalFocusIndex === 2) {
+ playBackSound();
+ setImportMode(null);
+ setImportUsername('');
+ setImportError('');
+ setModalFocusIndex(0);
+ }
+ }
+ }
+ return;
+ }
+ if (document.activeElement?.tagName === 'INPUT') return;
+ if (e.key === 'Escape') {
+ playBackSound();
+ setActiveView('main');
+ return;
+ }
+
+ if (e.key === 'ArrowRight') {
+ setFocusIndex(prev => (prev === null || prev >= ITEM_COUNT - 1) ? 0 : prev + 1);
+ } else if (e.key === 'ArrowLeft') {
+ setFocusIndex(prev => (prev === null || prev <= 0) ? ITEM_COUNT - 1 : prev - 1);
+ } else if (e.key === 'ArrowDown') {
+ if (focusIndex === null || focusIndex < TOP_BUTTONS_COUNT) {
+ setFocusIndex(SKINS_START_INDEX);
+ } else if (focusIndex < BACK_BUTTON_INDEX) {
+ const next = focusIndex + 4;
+ setFocusIndex(next >= BACK_BUTTON_INDEX ? BACK_BUTTON_INDEX : next);
+ }
+ } else if (e.key === 'ArrowUp') {
+ if (focusIndex === null) {
+ setFocusIndex(0);
+ } else if (focusIndex === BACK_BUTTON_INDEX) {
+ setFocusIndex(SKINS_START_INDEX + savedSkins.length - 1);
+ } else if (focusIndex >= SKINS_START_INDEX) {
+ const next = focusIndex - 4;
+ setFocusIndex(next < SKINS_START_INDEX ? 0 : next);
+ }
+ } else if (e.key === 'Enter' && focusIndex !== null) {
+ if (focusIndex === 0) handleImportClick();
+ else if (focusIndex === 1) handleDeleteActive();
+ else if (focusIndex === 2) { playClickSound(); TauriService.openInstanceFolder('Skins').catch(() => { }); }
+ else if (focusIndex < BACK_BUTTON_INDEX) {
+ handleSkinSelect(savedSkins[focusIndex - SKINS_START_INDEX]);
+ } else {
+ playBackSound();
+ setActiveView('main');
+ }
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [focusIndex, savedSkins.length, playBackSound, setActiveView, playClickSound, showImportModal, importMode, modalFocusIndex, importUsername]);
+
+ useEffect(() => {
+ if (focusIndex !== null) {
+ const el = containerRef.current?.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement;
+ if (el) el.focus();
+ }
+ }, [focusIndex]);
+
+ const handleImportClick = () => {
+ playClickSound();
+ setShowImportModal(true);
+ setModalFocusIndex(0);
+ };
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ if (file.type !== 'image/png') return;
+
+ const defaultName = file.name.replace('.png', '').substring(0, 16);
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const url = event.target?.result as string;
+ processSkinImage(url, defaultName);
+ };
+ reader.readAsDataURL(file);
+ e.target.value = '';
+ setShowImportModal(false);
+ setImportMode(null);
+ };
+
+ const handleSkinSelect = (skin: SavedSkin) => {
+ playClickSound();
+ setActiveSkinId(skin.id);
+ setSkinUrl(skin.url);
+ };
+
+ const isDefaultSkin = (id: string | null) => DEFAULT_SKINS.some(d => d.id === id);
+
+ const handleDeleteActive = () => {
+ if (!activeSkinId || isDefaultSkin(activeSkinId)) return;
+ playClickSound();
+ const updatedSkins = savedSkins.filter(s => s.id !== activeSkinId);
+ setSavedSkins(updatedSkins);
+ setSkinUrl('/images/Default.png');
+ setActiveSkinId('default');
+ };
+
+ const handleNameChange = (id: string, newName: string) => {
+ const updatedSkins = savedSkins.map(s => s.id === id ? { ...s, name: newName } : s);
+ setSavedSkins(updatedSkins);
+ };
+
+ const isActiveDefault = isDefaultSkin(activeSkinId) || (!activeSkinId && skinUrl === '/images/Default.png');
+
+ return (
+
+ Skin Library
+
+
+
+
+
+ setFocusIndex(0)}
+ onClick={handleImportClick}
+ className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
+ style={{ backgroundImage: focusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+ Import Skin
+
+
+ !isActiveDefault && setFocusIndex(1)}
+ onClick={handleDeleteActive}
+ className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${isActiveDefault ? 'text-gray-400 opacity-80 cursor-not-allowed' : (focusIndex === 1 ? 'text-[#FFFF55]' : 'text-white')}`}
+ style={{
+ backgroundImage: isActiveDefault ? "url('/images/Button_Background2.png')" : (focusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')"),
+ backgroundSize: '100% 100%',
+ imageRendering: 'pixelated'
+ }}
+ >
+ Delete Skin
+
+
+
+
+
+
setFocusIndex(2)}
+ onClick={() => { playClickSound(); TauriService.openInstanceFolder('Skins').catch(() => { }); }}
+ className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
+ style={{ backgroundImage: focusIndex === 2 ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+
+
+
+
+
+
+
+
+ {savedSkins.map((skin, i) => {
+ const idx = SKINS_START_INDEX + i;
+ const isActive = activeSkinId ? activeSkinId === skin.id : skinUrl === skin.url;
+ const isFocused = focusIndex === idx;
+ return (
+
setFocusIndex(idx)} className="flex flex-col items-center gap-1 w-32 outline-none">
+
+ {isActive && Active }
+
+
handleSkinSelect(skin)}
+ className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${(isActive || isFocused) ? 'border-[#FFFF55]' : 'border-[#373737] hover:border-[#A0A0A0]'}`}
+ >
+
+
+
handleNameChange(skin.id, e.target.value)}
+ className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${(isActive || isFocused) ? 'text-[#FFFF55]' : 'text-white'} ${isDefaultSkin(skin.id) ? 'pointer-events-none' : ''}`}
+ onClick={(e) => e.stopPropagation()} spellCheck={false}
+ readOnly={isDefaultSkin(skin.id)}
+ />
+
+ );
+ })}
+
+
+
+ setFocusIndex(BACK_BUTTON_INDEX)}
+ onClick={() => { playBackSound(); setActiveView('main'); }}
+ className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none hover:text-[#FFFF55] ${focusIndex === BACK_BUTTON_INDEX ? 'text-[#FFFF55]' : 'text-white'}`}
+ style={{ backgroundImage: focusIndex === BACK_BUTTON_INDEX ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+ Back
+
+
+ {showImportModal && (
+
+
+
Import Skin
+
+ {!importMode ? (
+
+ setModalFocusIndex(0)}
+ onClick={() => { playClickSound(); fileInputRef.current?.click(); }}
+ className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
+ style={{ backgroundImage: modalFocusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+ From File
+
+ setModalFocusIndex(1)}
+ onClick={() => { playClickSound(); setImportMode('username'); setModalFocusIndex(0); }}
+ className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white'}`}
+ style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+ From Username
+
+
+ ) : (
+
+ setImportUsername(e.target.value)}
+ onFocus={() => setModalFocusIndex(0)}
+ autoFocus
+ spellCheck={false}
+ className={`w-full h-12 bg-black/50 border-2 text-white px-4 text-xl outline-none transition-colors ${modalFocusIndex === 0 ? 'border-[#FFFF55]' : 'border-[#373737]'}`}
+ />
+
+ {importError && {importError} }
+
+ setModalFocusIndex(1)}
+ onClick={handleFetchUsername}
+ disabled={isImporting}
+ className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${isImporting ? 'opacity-50' : (modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white')}`}
+ style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+ {isImporting ? 'Fetching...' : 'Fetch Skin'}
+
+
+ )}
+
+
setModalFocusIndex(2)}
+ onClick={() => {
+ playBackSound();
+ setShowImportModal(false);
+ setImportMode(null);
+ setImportUsername('');
+ setImportError('');
+ setModalFocusIndex(0);
+ }}
+ className={`w-40 h-10 flex items-center justify-center transition-colors text-lg mc-text-shadow mt-6 outline-none ${modalFocusIndex === 2 ? 'text-[#FFFF55]' : 'text-white'}`}
+ style={{ backgroundImage: modalFocusIndex === 2 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
+ >
+ Cancel
+
+
+
+ )}
+
+ );
+});
+
+export default SkinsView;
diff --git a/src/components/views/ThemesView.tsx b/src/components/views/ThemesView.tsx
new file mode 100644
index 0000000..993c78a
--- /dev/null
+++ b/src/components/views/ThemesView.tsx
@@ -0,0 +1,157 @@
+import { useState, useEffect, useRef, memo } from "react";
+import { motion } from "framer-motion";
+import { TauriService, ThemePalette } from "../../services/TauriService";
+import { useUI, useConfig, useAudio } from "../../context/LauncherContext";
+
+const ThemesView = memo(function ThemesView() {
+ const { setActiveView } = useUI();
+ const { theme: currentTheme, setTheme } = useConfig();
+ const { playClickSound, playBackSound } = useAudio();
+
+ const [focusIndex, setFocusIndex] = useState(null);
+ const [externalPalettes, setExternalPalettes] = useState([]);
+ const containerRef = useRef(null);
+
+ const baseThemes = ["Default", "Modern"];
+
+ useEffect(() => {
+ TauriService.getExternalPalettes().then(setExternalPalettes);
+ }, []);
+
+ const totalPalettes = [...baseThemes, ...externalPalettes.map((p) => p.name)];
+ const ITEM_COUNT = 3; // Theme Cycle, Import Theme, Back
+
+ const handleImport = async () => {
+ playClickSound();
+ try {
+ const result = await TauriService.importTheme();
+ if (result === "success") {
+ const updated = await TauriService.getExternalPalettes();
+ setExternalPalettes(updated);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" || e.key === "Backspace") {
+ playBackSound();
+ setActiveView("main");
+ return;
+ }
+
+ if (e.key === "ArrowDown") {
+ setFocusIndex((prev) =>
+ prev === null || prev >= ITEM_COUNT - 1 ? 0 : prev + 1,
+ );
+ } else if (e.key === "ArrowUp") {
+ setFocusIndex((prev) =>
+ prev === null || prev <= 0 ? ITEM_COUNT - 1 : prev - 1,
+ );
+ } else if (e.key === "Enter" && focusIndex !== null) {
+ if (focusIndex === 0) {
+ playClickSound();
+ const currentIndex = totalPalettes.indexOf(currentTheme);
+ const nextIndex = (currentIndex + 1) % totalPalettes.length;
+ setTheme(totalPalettes[nextIndex]);
+ } else if (focusIndex === 1) {
+ handleImport();
+ } else {
+ playBackSound();
+ setActiveView("main");
+ }
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [
+ focusIndex,
+ currentTheme,
+ playClickSound,
+ playBackSound,
+ setActiveView,
+ setTheme,
+ totalPalettes,
+ ]);
+
+ useEffect(() => {
+ if (focusIndex !== null) {
+ const el = containerRef.current?.querySelector(
+ `[data-index="${focusIndex}"]`,
+ ) as HTMLElement;
+ if (el) el.focus();
+ }
+ }, [focusIndex]);
+
+ const getItemStyle = (index: number) => ({
+ backgroundImage:
+ focusIndex === index
+ ? "url('/images/button_highlighted.png')"
+ : "url('/images/Button_Background.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated" as const,
+ });
+
+ return (
+
+
+ Themes & Styles
+
+
+
+ setFocusIndex(0)}
+ onClick={() => {
+ playClickSound();
+ const currentIndex = totalPalettes.indexOf(currentTheme);
+ const nextIndex = (currentIndex + 1) % totalPalettes.length;
+ setTheme(totalPalettes[nextIndex]);
+ }}
+ className={`w-72 h-12 flex items-center justify-center px-4 relative transition-colors outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
+ style={getItemStyle(0)}
+ >
+
+ {currentTheme}
+
+
+
+ setFocusIndex(1)}
+ onClick={handleImport}
+ className={`w-72 h-12 flex items-center justify-center px-4 relative transition-colors outline-none border-none hover:text-[#FFFF55] ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
+ style={getItemStyle(1)}
+ >
+
+ Import Theme
+
+
+
+
+ setFocusIndex(2)}
+ onClick={() => {
+ playBackSound();
+ setActiveView("main");
+ }}
+ className={`w-72 h-12 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
+ style={getItemStyle(2)}
+ >
+ Back
+
+
+ );
+});
+
+export default ThemesView;
diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx
index c2eca9e..140d0b3 100644
--- a/src/components/views/VersionsView.tsx
+++ b/src/components/views/VersionsView.tsx
@@ -1,96 +1,594 @@
-import React from 'react';
-import { TauriService } from '../../services/tauri';
-import { ReinstallModalData } from '../../types';
+import { useState, useEffect, useRef, memo } from "react";
+import { motion } from "framer-motion";
+import { TauriService } from "../../services/TauriService";
+import CustomTUModal from "../modals/CustomTUModal";
+import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
-interface VersionsViewProps {
- installedStatus: Record;
- installingInstance: string | null;
- executeInstall: (id: string, url: string) => void;
- setReinstallModal: (data: ReinstallModalData | null) => void;
- playSfx: (name: string, multiplier?: number) => void;
-}
+const VersionsView = memo(function VersionsView() {
+ const { setActiveView } = useUI();
+ const { profile: selectedProfile, setProfile: setSelectedProfile } = useConfig();
+ const { playClickSound, playBackSound, playSfx } = useAudio();
+ const { editions, installs: installedVersions, toggleInstall, handleUninstall: onUninstall, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId } = useGame();
-export const VersionsView: React.FC = ({
- installedStatus,
- installingInstance,
- executeInstall,
- setReinstallModal,
- playSfx,
-}) => {
- const versions = [
- {
- id: "vanilla_tu19",
- name: "Vanilla Nightly (TU19)",
- desc: "Leaked 4J Studios build.",
- url: "https://huggingface.co/datasets/KayJann/emerald-legacy-assets/resolve/main/emerald_tu19_vanilla.zip"
- },
- {
- id: "vanilla_tu24",
- name: "Vanilla TU24",
- desc: "Horses and Wither update.",
- url: "https://huggingface.co/datasets/KayJann/emerald-legacy-assets/resolve/main/emerald_tu24_vanilla.zip"
- }
- ];
+ const [focusRow, setFocusRow] = useState(0);
+ const [focusCol, setFocusCol] = useState(0);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+ const [editingEdition, setEditingEdition] = useState(null);
+ const containerRef = useRef(null);
+
+ const ITEM_COUNT = editions.length + 2;
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (document.activeElement?.tagName === "INPUT") return;
+ if (e.key === "Escape" || e.key === "Backspace") {
+ playBackSound();
+ setActiveView("main");
+ return;
+ }
+
+ if (e.key === "ArrowDown") {
+ setFocusRow((prev) => (prev >= ITEM_COUNT - 1 ? 0 : prev + 1));
+ setFocusCol(0);
+ } else if (e.key === "ArrowUp") {
+ setFocusRow((prev) => (prev <= 0 ? ITEM_COUNT - 1 : prev - 1));
+ setFocusCol(0);
+ } else if (e.key === "ArrowRight") {
+ if (focusRow < editions.length) {
+ const edition = editions[focusRow];
+ const isInstalled = installedVersions.includes(edition.id);
+ const isCustom = edition.id.startsWith("custom_");
+ const hasCredits = !isCustom && edition.credits;
+
+ let maxCol = 1;
+ if (isInstalled) maxCol = 3;
+ if (isCustom) maxCol = isInstalled ? 5 : 3;
+ if (hasCredits) maxCol = Math.max(maxCol, 0); // credits button is at col -1
+
+ setFocusCol((prev) => (prev < maxCol ? prev + 1 : prev));
+ }
+ } else if (e.key === "ArrowLeft") {
+ if (focusRow < editions.length) {
+ const edition = editions[focusRow];
+ const isCustom = edition.id.startsWith("custom_");
+ const hasCredits = !isCustom && edition.credits;
+
+ if (hasCredits && focusCol > -1) {
+ setFocusCol(-1);
+ } else if (focusCol > 0) {
+ setFocusCol((prev) => prev - 1);
+ }
+ } else {
+ setFocusCol((prev) => (prev > 0 ? prev - 1 : prev));
+ }
+ } else if (e.key === "Enter") {
+ if (focusRow < editions.length) {
+ const edition = editions[focusRow];
+ const isInstalled = installedVersions.includes(edition.id);
+ const isCustom = edition.id.startsWith("custom_");
+
+ if (focusCol === -1) {
+ // Credits button
+ if (edition.credits) {
+ playClickSound();
+ window.open(edition.credits.url, '_blank');
+ }
+ } else if (focusCol === 1) {
+ if (!downloadingId) {
+ playClickSound();
+ toggleInstall(edition.id);
+ }
+ } else if (focusCol === 2) {
+ if (isInstalled) {
+ playClickSound();
+ TauriService.openInstanceFolder(edition.id);
+ } else if (isCustom) {
+ playClickSound();
+ setEditingEdition(edition);
+ setIsImportModalOpen(true);
+ }
+ } else if (focusCol === 3) {
+ if (isInstalled) {
+ playBackSound();
+ onUninstall(edition.id);
+ } else if (isCustom) {
+ playBackSound();
+ onDeleteEdition(edition.id);
+ }
+ } else if (focusCol === 4) {
+ if (isCustom) {
+ playClickSound();
+ setEditingEdition(edition);
+ setIsImportModalOpen(true);
+ }
+ } else if (focusCol === 5) {
+ if (isCustom) {
+ playBackSound();
+ onDeleteEdition(edition.id);
+ }
+ }
+ } else if (focusRow === editions.length) {
+ playClickSound();
+ setIsImportModalOpen(true);
+ } else {
+ playBackSound();
+ setActiveView("main");
+ }
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [editions, focusRow, focusCol, downloadingId, installedVersions, onUninstall, onDeleteEdition, ITEM_COUNT]);
+
+ useEffect(() => {
+ const el = containerRef.current?.querySelector(
+ `[data-row="${focusRow}"][data-col="${focusCol}"]`,
+ ) as HTMLElement;
+ if (el) el.focus();
+ }, [focusRow, focusCol]);
return (
-
-
Instances
-
- {versions.map(v => (
-
-
-
- {installedStatus[v.id] ? (
- <>
- {
- playSfx('pop.wav');
- TauriService.openInstanceFolder(v.id);
- }}
- className="legacy-btn px-4 py-2 text-xl"
- >
- Folder
-
- {
- playSfx('click.wav');
- setReinstallModal({ id: v.id, url: v.url });
- }}
- disabled={!!installingInstance}
- className="legacy-btn px-4 py-2 text-xl reinstall-btn"
- >
- Reinstall
-
- >
- ) : (
- {
- playSfx('click.wav');
- executeInstall(v.id, v.url);
- }}
- disabled={!!installingInstance}
- className="legacy-btn px-6 py-2 text-xl"
- >
- INSTALL
-
- )}
-
-
- ))}
+
+
+ Versions
+
- {['TU75', 'TU9', 'Modded Pack'].map(v => (
-
-
-
Vanilla {v}
-
Legacy version.
-
-
SOON
+
+
+
+ {editions.map((edition: any, i: number) => {
+ const isInstalled = installedVersions.includes(edition.id);
+ const isSelected = selectedProfile === edition.id;
+ const isRowFocused = focusRow === i;
+ const isCustom = edition.id.startsWith("custom_");
+ const isPlaceholder = edition.id === "lmrp_placeholder";
+
+ return (
+
{
+ if (!isPlaceholder) {
+ setFocusRow(i);
+ setFocusCol(0);
+ }
+ }}
+ onClick={() => {
+ if (!isPlaceholder && isInstalled) {
+ playClickSound();
+ setSelectedProfile(edition.id);
+ }
+ }}
+ style={{
+ backdropFilter: 'blur(4px)',
+ cursor: isPlaceholder ? 'not-allowed' : isInstalled ? 'pointer' : 'default',
+ imageRendering: 'pixelated',
+ borderRadius: '0'
+ }}
+ >
+
+
+
+ {edition.name}
+
+ {isCustom && (
+
+ Custom
+
+ )}
+ {edition.id === "revelations_edition" && (
+ <>
+
+ New
+
+
+ Recommended
+
+ >
+ )}
+ {edition.id === "360revived" && (
+
+ New
+
+ )}
+ {edition.id === "lmrp_placeholder" && (
+
+ Coming Soon
+
+ )}
+ {!isCustom && edition.credits && (
+
+ by {edition.credits.developer}
+
+ )}
+
+
+ {edition.desc}
+
+
+
+ {!isPlaceholder && (
+
+ {!isCustom && edition.credits && (
+
{
+ e.stopPropagation();
+ setFocusRow(i);
+ setFocusCol(-1);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ playClickSound();
+ window.open(edition.credits.url, '_blank');
+ }}
+ className={`mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all`}
+ style={{
+ backgroundImage:
+ isRowFocused && focusCol === -1
+ ? "url('/images/Button_Square_Highlighted.png')"
+ : "url('/images/Button_Square.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ title={`Credits: ${edition.credits.developer} (${edition.credits.platform})`}
+ >
+ {edition.credits?.platform === "codeberg" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ {!isInstalled ? (
+
{
+ e.stopPropagation();
+ setFocusRow(i);
+ setFocusCol(1);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (!downloadingId) {
+ playClickSound();
+ toggleInstall(edition.id);
+ }
+ }}
+ className={`mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all ${downloadingId === edition.id ? "opacity-100" : downloadingId ? "opacity-50" : ""}`}
+ style={{
+ backgroundImage:
+ isRowFocused && focusCol === 1
+ ? "url('/images/Button_Square_Highlighted.png')"
+ : "url('/images/Button_Square.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+ {downloadingId === edition.id ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+ <>
+
{
+ e.stopPropagation();
+ setFocusRow(i);
+ setFocusCol(2);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ playClickSound();
+ TauriService.openInstanceFolder(edition.id);
+ }}
+ className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
+ style={{
+ backgroundImage:
+ isRowFocused && focusCol === 2
+ ? "url('/images/Button_Square_Highlighted.png')"
+ : "url('/images/Button_Square.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+
+
+
{
+ e.stopPropagation();
+ setFocusRow(i);
+ setFocusCol(3);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ playBackSound();
+ onUninstall(edition.id);
+ }}
+ className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
+ style={{
+ backgroundImage:
+ isRowFocused && focusCol === 3
+ ? "url('/images/Button_Square_Highlighted.png')"
+ : "url('/images/Button_Square.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+
+
+
+
+
+
+
+ >
+ )}
+ {isCustom && (
+ <>
+
{
+ e.stopPropagation();
+ setFocusRow(i);
+ setFocusCol(isInstalled ? 4 : 2);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ playClickSound();
+ setEditingEdition(edition);
+ setIsImportModalOpen(true);
+ }}
+ className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
+ style={{
+ backgroundImage:
+ isRowFocused && focusCol === (isInstalled ? 4 : 2)
+ ? "url('/images/Button_Square_Highlighted.png')"
+ : "url('/images/Button_Square.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+
+
+
+
+
+
{
+ e.stopPropagation();
+ setFocusRow(i);
+ setFocusCol(isInstalled ? 5 : 3);
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ playBackSound();
+ onDeleteEdition(edition.id);
+ }}
+ className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
+ style={{
+ backgroundImage:
+ isRowFocused && focusCol === (isInstalled ? 5 : 3)
+ ? "url('/images/Button_Square_Highlighted.png')"
+ : "url('/images/Button_Square.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+ );
+ })}
- ))}
+
-
+
+
+ {
+ setFocusRow(editions.length);
+ setFocusCol(0);
+ }}
+ onClick={() => {
+ playClickSound();
+ setIsImportModalOpen(true);
+ }}
+ className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${focusRow === editions.length ? "text-[#50C878]" : "text-white"}`}
+ style={{
+ backgroundImage:
+ focusRow === editions.length
+ ? "url('/images/button_highlighted.png')"
+ : "url('/images/Button_Background.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+ Import Custom TU
+
+
+ {
+ setFocusRow(editions.length + 1);
+ setFocusCol(0);
+ }}
+ onClick={() => {
+ playBackSound();
+ setActiveView("main");
+ }}
+ className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${focusRow === editions.length + 1 ? "text-[#50C878]" : "text-white"}`}
+ style={{
+ backgroundImage:
+ focusRow === editions.length + 1
+ ? "url('/images/button_highlighted.png')"
+ : "url('/images/Button_Background.png')",
+ backgroundSize: "100% 100%",
+ imageRendering: "pixelated",
+ }}
+ >
+ Back
+
+
+
+ {
+ setIsImportModalOpen(false);
+ setEditingEdition(null);
+ }}
+ onImport={(ed: any) => {
+ if (editingEdition) {
+ onUpdateEdition(editingEdition.id, ed);
+ } else {
+ const id = onAddEdition(ed);
+ setSelectedProfile(id);
+ }
+ }}
+ playSfx={playSfx}
+ editingEdition={editingEdition}
+ />
+
);
-};
+});
+
+export default VersionsView;
diff --git a/src/components/views/WorkshopView.tsx b/src/components/views/WorkshopView.tsx
new file mode 100644
index 0000000..d6b4944
--- /dev/null
+++ b/src/components/views/WorkshopView.tsx
@@ -0,0 +1,47 @@
+import { useState, useEffect, memo } from 'react';
+import { motion } from 'framer-motion';
+import { useUI, useAudio, useConfig } from '../../context/LauncherContext';
+
+const WorkshopView = memo(function WorkshopView() {
+ const { setActiveView } = useUI();
+ const { playBackSound } = useAudio();
+ const [backHover, setBackHover] = useState(false);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' || e.key === 'Backspace') {
+ playBackSound();
+ setActiveView('main');
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [playBackSound, setActiveView]);
+
+ return (
+
+ Workshop
+
+
+ Workshop support coming soon...
+
+
+ setBackHover(true)}
+ onMouseLeave={() => setBackHover(false)}
+ onClick={() => { playBackSound(); setActiveView('main'); }}
+ className={`w-72 h-12 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${backHover ? 'text-[#FFFF55]' : 'text-white'}`}
+ style={{
+ backgroundImage: backHover ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')",
+ backgroundSize: '100% 100%',
+ imageRendering: 'pixelated'
+ }}
+ >
+ Back
+
+
+ );
+});
+
+export default WorkshopView;
\ No newline at end of file
diff --git a/src/context/LauncherContext.tsx b/src/context/LauncherContext.tsx
new file mode 100644
index 0000000..da712a0
--- /dev/null
+++ b/src/context/LauncherContext.tsx
@@ -0,0 +1,201 @@
+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";
+import { useGameManager } from "../hooks/useGameManager";
+import { useSkinSync } from "../hooks/useSkinSync";
+import { useDiscordRPC } from "../hooks/useDiscordRPC";
+import { useGamepad } from "../hooks/useGamepad";
+import { useUpdateCheck } from "../hooks/useUpdateCheck";
+
+interface UIContextType {
+ activeView: string;
+ setActiveView: (view: string) => void;
+ showIntro: boolean;
+ setShowIntro: (show: boolean) => void;
+ logoAnimDone: boolean;
+ setLogoAnimDone: (done: boolean) => void;
+ isUiHidden: boolean;
+ setIsUiHidden: (hidden: boolean) => void;
+ isWindowVisible: boolean;
+ showCredits: boolean;
+ setShowCredits: (show: boolean) => void;
+ showSpecialThanks: boolean;
+ setShowSpecialThanks: (show: boolean) => void;
+ focusSection: "menu" | "skin";
+ setFocusSection: (section: "menu" | "skin") => void;
+ onNavigateToSkin: () => void;
+ onNavigateToMenu: () => void;
+ connected: boolean;
+ updateMessage: string | null;
+ clearUpdateMessage: () => void;
+}
+const UIContext = createContext
(undefined);
+
+export const ConfigContext = createContext | undefined>(undefined);
+export const AudioContext = createContext | undefined>(undefined);
+export const GameContext = createContext | undefined>(undefined);
+export const SkinContext = createContext | undefined>(undefined);
+
+export function LauncherProvider({ children }: { children: React.ReactNode }) {
+ const [showIntro, setShowIntro] = useState(true);
+ const [logoAnimDone, setLogoAnimDone] = useState(false);
+ const [activeView, setActiveView] = useState("main");
+ const [isUiHidden, setIsUiHidden] = useState(false);
+ const [isWindowVisible, setIsWindowVisible] = useState(true);
+ const [showCredits, setShowCredits] = useState(false);
+ const [showSpecialThanks, setShowSpecialThanks] = useState(false);
+ const [focusSection, setFocusSection] = useState<"menu" | "skin">("menu");
+
+ const { updateMessage, clearUpdateMessage } = useUpdateCheck();
+
+ const configRaw = useAppConfig();
+ const skinSync = useSkinSync();
+ const gameRaw = useGameManager({
+ profile: configRaw.profile,
+ setProfile: configRaw.setProfile,
+ customEditions: configRaw.customEditions,
+ setCustomEditions: configRaw.setCustomEditions,
+ keepLauncherOpen: configRaw.keepLauncherOpen,
+ });
+ const audioRaw = useAudioController({
+ musicVol: configRaw.musicVol,
+ sfxVol: configRaw.sfxVol,
+ showIntro,
+ isGameRunning: gameRaw.isGameRunning,
+ 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.legacyMode, configRaw.keepLauncherOpen, configRaw.enableTrayIcon,
+ configRaw.animationsEnabled, configRaw.saveConfig, configRaw.setUsername, configRaw.setTheme,
+ configRaw.setLayout, configRaw.setVfxEnabled, configRaw.setAnimationsEnabled,
+ configRaw.setRpcEnabled, configRaw.setMusicVol, configRaw.setSfxVol, configRaw.setIsDayTime,
+ configRaw.setProfile, configRaw.setLinuxRunner, configRaw.setPerfBoost, configRaw.setCustomEditions,
+ configRaw.isLoaded, configRaw.hasCompletedSetup, configRaw.setHasCompletedSetup
+ ]);
+
+ const game = useMemo(() => gameRaw, [
+ gameRaw.installs, gameRaw.isGameRunning, gameRaw.downloadProgress,
+ gameRaw.downloadingId, gameRaw.editions, gameRaw.isRunnerDownloading,
+ gameRaw.runnerDownloadProgress, gameRaw.error, gameRaw.updateCustomEdition, configRaw.profile,
+ gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.toggleInstall, gameRaw.handleUninstall,
+ gameRaw.addCustomEdition, gameRaw.deleteCustomEdition, gameRaw.downloadRunner, gameRaw.checkInstalls
+ ]);
+
+ const audio = useMemo(() => audioRaw, [
+ audioRaw.currentTrack, audioRaw.splashIndex, audioRaw.tracks, audioRaw.splashes
+ ]);
+
+ useDiscordRPC({
+ rpcEnabled: config.rpcEnabled,
+ showIntro,
+ username: config.username,
+ profile: config.profile,
+ activeView,
+ isGameRunning: game.isGameRunning,
+ downloadProgress: game.downloadProgress,
+ downloadingId: game.downloadingId,
+ editions: game.editions,
+ isWindowVisible,
+ });
+
+ const { connected } = useGamepad({ playSfx: audio.playSfx, isWindowVisible });
+
+ const onNavigateToSkin = useCallback(() => setFocusSection("skin"), []);
+ const onNavigateToMenu = useCallback(() => setFocusSection("menu"), []);
+
+ useEffect(() => {
+ if (activeView === "main") {
+ audioRaw.setSplashIndex(-1);
+ }
+ }, [activeView]);
+
+ useEffect(() => {
+ if (config.isLoaded && config.profile) {
+ TauriService.syncDlc(config.profile).catch(console.error);
+ }
+ }, [config.profile, config.isLoaded]);
+
+ useEffect(() => {
+ if (config.isLoaded) {
+ config.saveConfig(skinSync.skinBase64);
+ }
+ }, [
+ config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
+ config.perfBoost, config.customEditions, config.profile, config.keepLauncherOpen,
+ config.enableTrayIcon, config.vfxEnabled, config.animationsEnabled,
+ config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.isLoaded
+ ]);
+
+ useEffect(() => {
+ const setupVisibilityDetection = async () => {
+ try {
+ const { listen } = await import("@tauri-apps/api/event");
+
+ const unlistenClose = await listen("tauri://close-requested", () => {
+ console.log("Window close requested - hiding music");
+ setIsWindowVisible(false);
+ });
+
+ const unlistenShow = await listen("tauri://window-shown", () => {
+ console.log("Window shown - resuming music");
+ setIsWindowVisible(true);
+ });
+
+ const unlistenFocus = await listen("tauri://focus", () => {
+ console.log("Window focused - resuming music");
+ setIsWindowVisible(true);
+ });
+
+ const unlistenBlur = await listen("tauri://blur", () => {
+ console.log("Window blurred - checking visibility");
+ });
+
+ return () => {
+ unlistenClose();
+ unlistenShow();
+ unlistenFocus();
+ unlistenBlur();
+ };
+ } catch (error) {
+ console.error("Failed to setup visibility detection:", error);
+ setIsWindowVisible(true);
+ }
+ };
+
+ setupVisibilityDetection();
+ }, []);
+
+ const uiValue = useMemo(() => ({
+ activeView, setActiveView, showIntro, setShowIntro,
+ logoAnimDone, setLogoAnimDone, isUiHidden, setIsUiHidden,
+ isWindowVisible,
+ showCredits, setShowCredits, showSpecialThanks, setShowSpecialThanks, focusSection, setFocusSection,
+ onNavigateToSkin, onNavigateToMenu, connected,
+ updateMessage, clearUpdateMessage
+ }), [activeView, showIntro, logoAnimDone, isUiHidden, isWindowVisible, showCredits, showSpecialThanks, focusSection, onNavigateToSkin, onNavigateToMenu, connected, updateMessage, clearUpdateMessage]);
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
+
+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; };
diff --git a/src/css/App.css b/src/css/App.css
new file mode 100644
index 0000000..1d76bf5
--- /dev/null
+++ b/src/css/App.css
@@ -0,0 +1 @@
+/* empty :P */
\ No newline at end of file
diff --git a/src/css/index.css b/src/css/index.css
new file mode 100644
index 0000000..433ff46
--- /dev/null
+++ b/src/css/index.css
@@ -0,0 +1,114 @@
+@import "tailwindcss";
+
+@font-face {
+ font-family: 'Mojangles';
+ src: url('/fonts/Mojangles.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+.gamepad-connected-indicator {
+ position: fixed;
+ bottom: 10px;
+ left: 10px;
+ color: #FFFF55;
+ font-size: 10px;
+ z-index: 1000;
+ opacity: 0.5;
+}
+
+.mc-setup-nav-btn {
+ border: 4px solid transparent;
+ border-image: url('/images/Button_Background.png') 4 stretch;
+ image-rendering: pixelated;
+ background: rgba(0, 0, 0, 0.6);
+ transition: all 0.2s ease;
+}
+
+.mc-setup-nav-btn:hover:not(:disabled) {
+ transform: scale(1.05);
+}
+
+.mc-setup-nav-btn:active:not(:disabled) {
+ transform: scale(0.95);
+}
+
+.mc-setup-nav-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+body {
+ margin: 0;
+ background-color: #000;
+ font-family: 'Mojangles', monospace;
+ -webkit-font-smoothing: none;
+}
+
+.mc-button {
+ background-image: url('/images/button.png');
+ background-size: 100% 100%;
+ image-rendering: pixelated;
+ transition: none;
+}
+
+.mc-button:hover:not(:disabled) {
+ background-image: url('/images/button_highlighted.png');
+}
+
+.mc-text-shadow {
+ text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
+}
+
+.mc-text-shadow-hover {
+ text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
+}
+
+
+@keyframes sga-burst {
+ 0% {
+ transform: translate(-50%, -50%) scale(0.5);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY))) scale(1.2) rotate(var(--rot));
+ opacity: 0;
+ }
+}
+
+.particle-burst {
+ animation: sga-burst 0.8s ease-out forwards;
+ filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
+}
+
+::-webkit-scrollbar {
+ width: 14px;
+}
+
+::-webkit-scrollbar-track {
+ background: url('/images/backgroundframe.png') center;
+ background-size: 100% auto;
+ image-rendering: pixelated;
+}
+
+::-webkit-scrollbar-thumb {
+ background: url('/images/SliderHandlerBackground.png') no-repeat center;
+ background-size: 100% 100%;
+ image-rendering: pixelated;
+ cursor: pointer;
+}
+
+.no-animations * {
+ transition: none !important;
+ animation: none !important;
+}
+
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
+
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
\ No newline at end of file
diff --git a/src/hooks/useAppConfig.ts b/src/hooks/useAppConfig.ts
new file mode 100644
index 0000000..478d2a8
--- /dev/null
+++ b/src/hooks/useAppConfig.ts
@@ -0,0 +1,111 @@
+import { useState, useEffect, useCallback } from "react";
+import { useLocalStorage } from "./useLocalStorage";
+import { TauriService } from "../services/TauriService";
+
+export function useAppConfig() {
+ const [username, setUsername] = useLocalStorage("lce-username", "Steve");
+ const [theme, setTheme] = useLocalStorage("lce-theme", "Modern");
+ const [layout, setLayout] = useLocalStorage("lce-layout", "KBM");
+ const [vfxEnabled, setVfxEnabled] = useLocalStorage("lce-vfx", true);
+ const [animationsEnabled, setAnimationsEnabled] = useLocalStorage("lce-animations", true);
+ const [rpcEnabled, setRpcEnabled] = useLocalStorage("discord-rpc", true);
+ const [musicVol, setMusicVol] = useLocalStorage("lce-music", 50);
+ const [sfxVol, setSfxVol] = useLocalStorage("lce-sfx", 100);
+ const [isDayTime, setIsDayTime] = useLocalStorage("lce-daytime", true);
+ const [profile, setProfile] = useLocalStorage("lce-profile", "legacy_evolved");
+ const [legacyMode, setLegacyMode] = useLocalStorage("lce-legacy-mode", false);
+ const [keepLauncherOpen, setKeepLauncherOpen] = useLocalStorage("lce-keep-open", false);
+ const [enableTrayIcon, setEnableTrayIcon] = useLocalStorage("lce-tray-icon", true);
+ const [hasCompletedSetup, setHasCompletedSetup] = useLocalStorage("lce-setup-completed", false);
+
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [linuxRunner, setLinuxRunner] = useState();
+ const [perfBoost, setPerfBoost] = useState(false);
+ const [customEditions, setCustomEditions] = useState([]);
+
+ useEffect(() => {
+ TauriService.loadConfig().then((config) => {
+ if (config.username) setUsername(config.username);
+ if (config.themeStyleId) setTheme(config.themeStyleId);
+ if (config.linuxRunner) setLinuxRunner(config.linuxRunner);
+ if (config.appleSiliconPerformanceBoost !== undefined)
+ setPerfBoost(config.appleSiliconPerformanceBoost);
+ if (config.customEditions) setCustomEditions(config.customEditions);
+ if (config.profile) setProfile(config.profile);
+ if (config.keepLauncherOpen !== undefined) setKeepLauncherOpen(config.keepLauncherOpen);
+ if (config.enableTrayIcon !== undefined) setEnableTrayIcon(config.enableTrayIcon);
+ if (config.vfxEnabled !== undefined) setVfxEnabled(config.vfxEnabled);
+ if (config.animationsEnabled !== undefined) setAnimationsEnabled(config.animationsEnabled);
+ if (config.rpcEnabled !== undefined) setRpcEnabled(config.rpcEnabled);
+ if (config.musicVol !== undefined && config.musicVol !== null) setMusicVol(config.musicVol);
+ if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
+ if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
+ setIsLoaded(true);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (isLoaded) {
+ TauriService.updateTrayIcon(enableTrayIcon);
+ }
+ }, [enableTrayIcon, isLoaded]);
+
+ const saveConfig = useCallback((skinBase64?: string | null) => {
+ TauriService.saveConfig({
+ username,
+ skinBase64: skinBase64 || undefined,
+ themeStyleId: theme,
+ linuxRunner,
+ appleSiliconPerformanceBoost: perfBoost,
+ profile,
+ customEditions,
+ keepLauncherOpen,
+ enableTrayIcon,
+ animationsEnabled,
+ vfxEnabled,
+ rpcEnabled,
+ musicVol,
+ sfxVol,
+ legacyMode,
+ }).catch(console.error);
+ }, [username, theme, linuxRunner, perfBoost, profile, customEditions, keepLauncherOpen, enableTrayIcon, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode]);
+
+ return {
+ username,
+ setUsername,
+ theme,
+ setTheme,
+ layout,
+ setLayout,
+ vfxEnabled,
+ setVfxEnabled,
+ animationsEnabled,
+ setAnimationsEnabled,
+ rpcEnabled,
+ setRpcEnabled,
+ musicVol: musicVol ?? 50,
+ setMusicVol,
+ sfxVol: sfxVol ?? 100,
+ setSfxVol,
+ isDayTime,
+ setIsDayTime,
+ legacyMode,
+ setLegacyMode,
+ keepLauncherOpen,
+ setKeepLauncherOpen,
+ enableTrayIcon,
+ setEnableTrayIcon,
+ profile,
+ setProfile,
+ linuxRunner,
+ setLinuxRunner,
+ perfBoost,
+ setPerfBoost,
+ customEditions,
+ setCustomEditions,
+ isLoaded,
+ hasCompletedSetup,
+ setHasCompletedSetup,
+ saveConfig,
+ };
+}
diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts
deleted file mode 100644
index 6839d6c..0000000
--- a/src/hooks/useAudio.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useRef, useEffect } from 'react';
-import { playSfx as playSfxService, ensureAudio } from '../services/audio';
-
-export const useAudio = (musicVol: number, sfxVol: number, isMuted: boolean) => {
- const musicRef = useRef(null);
- const lastTrack = useRef(0);
-
- useEffect(() => {
- if (musicRef.current) {
- musicRef.current.volume = isMuted ? 0 : musicVol;
- }
- }, [musicVol, isMuted]);
-
- const playRandomMusic = () => {
- if (!musicRef.current) return;
- let track = Math.floor(Math.random() * 5) + 1;
- if (track === lastTrack.current) track = (track % 5) + 1;
- lastTrack.current = track;
- musicRef.current.src = `/music/music${track}.ogg`;
- musicRef.current.volume = isMuted ? 0 : musicVol;
- musicRef.current.play().catch(() => {});
- };
-
- const playSfx = (name: string, multiplier: number = 1.0) => {
- playSfxService(name, sfxVol, isMuted, multiplier);
- };
-
- return {
- musicRef,
- playRandomMusic,
- playSfx,
- ensureAudio,
- };
-};
diff --git a/src/hooks/useAudioController.ts b/src/hooks/useAudioController.ts
new file mode 100644
index 0000000..e29c3c9
--- /dev/null
+++ b/src/hooks/useAudioController.ts
@@ -0,0 +1,183 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+
+const TRACKS = [
+ "/music/Blind Spots.ogg",
+ "/music/Key.ogg",
+ "/music/Living Mice.ogg",
+ "/music/Oxygene.ogg",
+ "/music/Subwoofer Lullaby.ogg",
+];
+
+const SPLASHES = [
+ "Legacy is back!", "Pixelated goodness!", "Console Edition vibe!", "100% Not Microsoft!",
+ "Symmetry is key!", "Does anyone even read these?", "Task failed successfully.",
+ "Hardware accelerated!", "It's a feature, not a bug.", "Look behind you.",
+ "Works on my machine.", "Now gluten-free!", "Mom, get the camera!", "Batteries not included.",
+ "May contain nuts.", "Press Alt+F4 for diamonds!", "Downloading more RAM...",
+ "Reinventing the wheel!", "The cake is a lie.", "Powered by copious amounts of coffee.",
+ "I'm running out of ideas.", "That's no moon...", "Now with 100% more nostalgia!",
+ "Legacy is the new modern.", "No microtransactions!", "As seen on TV!", "Ironic, isn't it?",
+ "Creeper? Aww man.", "Technoblade never dies!",
+];
+
+interface AudioControllerProps {
+ musicVol: number;
+ sfxVol: number;
+ showIntro: boolean;
+ isGameRunning: boolean;
+ isWindowVisible: boolean;
+}
+
+export function useAudioController({ musicVol, sfxVol, showIntro, isGameRunning, isWindowVisible }: AudioControllerProps) {
+ const [currentTrack, setCurrentTrack] = useState(0);
+ const [audioElement, setAudioElement] = useState(null);
+ const [splashIndex, setSplashIndex] = useState(-1);
+ const musicPausedRef = useRef<{ at: number; track: number } | null>(null);
+ const fadeIntervalRef = useRef(null);
+
+ const playSfx = useCallback((file: string) => {
+ const a = new Audio(`/sounds/${file}`);
+ a.volume = sfxVol / 100;
+ a.play().catch(() => { });
+ }, [sfxVol]);
+
+ const playClickSound = useCallback(() => playSfx("click.wav"), [playSfx]);
+ const playBackSound = useCallback(() => playSfx("back.ogg"), [playSfx]);
+ const playSplashSound = useCallback(() => playSfx("orb.ogg"), [playSfx]);
+
+ const fadeOut = useCallback((audio: HTMLAudioElement, duration: number = 500) => {
+ return new Promise((resolve) => {
+ if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
+ const initialVolume = audio.volume;
+ const steps = 5;
+ const stepDuration = duration / steps;
+ let currentStep = 0;
+ fadeIntervalRef.current = setInterval(() => {
+ currentStep++;
+ const progress = currentStep / steps;
+ audio.volume = initialVolume * (1 - progress);
+ if (currentStep >= steps) {
+ if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
+ fadeIntervalRef.current = null;
+ audio.pause();
+ audio.volume = initialVolume;
+ resolve();
+ }
+ }, stepDuration);
+ });
+ }, []);
+
+ const fadeIn = useCallback((audio: HTMLAudioElement, targetVolume: number, duration: number = 500) => {
+ return new Promise((resolve) => {
+ if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
+ audio.volume = 0;
+ const playPromise = audio.play();
+ if (playPromise !== undefined) {
+ playPromise.catch(() => { });
+ }
+
+ const steps = 5;
+ const stepDuration = duration / steps;
+ let currentStep = 0;
+ fadeIntervalRef.current = setInterval(() => {
+ currentStep++;
+ const progress = currentStep / steps;
+ audio.volume = targetVolume * progress;
+ if (currentStep >= steps) {
+ if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
+ fadeIntervalRef.current = null;
+ audio.volume = targetVolume;
+ resolve();
+ }
+ }, stepDuration);
+ });
+ }, []);
+
+ const cycleSplash = useCallback(() => {
+ playSplashSound();
+ let newIndex;
+ do {
+ newIndex = Math.floor(Math.random() * SPLASHES.length);
+ } while (newIndex === splashIndex && SPLASHES.length > 1);
+ setSplashIndex(newIndex);
+ }, [playSplashSound, splashIndex]);
+
+ useEffect(() => {
+ if (showIntro) return;
+ if (audioElement) return;
+
+ const audio = new Audio(TRACKS[currentTrack]);
+ audio.volume = musicVol / 100;
+ const handleEnded = () => setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
+ audio.addEventListener("ended", handleEnded);
+
+ const playPromise = audio.play();
+ if (playPromise !== undefined) {
+ playPromise.catch(() => {
+ console.log("Autoplay prevented, waiting for user interaction");
+ const startMusic = () => {
+ audio.play().catch(() => { });
+ document.removeEventListener("click", startMusic);
+ document.removeEventListener("keydown", startMusic);
+ };
+ document.addEventListener("click", startMusic, { once: true });
+ document.addEventListener("keydown", startMusic, { once: true });
+ });
+ }
+
+ setAudioElement(audio);
+ return () => {
+ audio.removeEventListener("ended", handleEnded);
+ audio.pause();
+ };
+ }, [showIntro, audioElement, currentTrack, musicVol]);
+
+ useEffect(() => {
+ if (!audioElement) return;
+ audioElement.src = TRACKS[currentTrack];
+ audioElement.play().catch(() => { });
+ }, [currentTrack]);
+
+ useEffect(() => {
+ if (!audioElement) return;
+ const shouldPause = isGameRunning || !isWindowVisible;
+
+ if (shouldPause) {
+ if (!audioElement.paused || fadeIntervalRef.current) {
+ if (!musicPausedRef.current) {
+ musicPausedRef.current = {
+ at: audioElement.currentTime,
+ track: currentTrack,
+ };
+ }
+ fadeOut(audioElement, 500);
+ }
+ } else if (musicPausedRef.current) {
+ const { at, track } = musicPausedRef.current;
+ musicPausedRef.current = null;
+ if (track === currentTrack) {
+ audioElement.currentTime = at;
+ }
+ fadeIn(audioElement, musicVol / 100, 500);
+ }
+ }, [isGameRunning, isWindowVisible, audioElement, currentTrack, musicVol, fadeOut, fadeIn]);
+
+ useEffect(() => {
+ if (audioElement) {
+ audioElement.volume = musicVol / 100;
+ }
+ }, [musicVol, audioElement]);
+
+ return {
+ currentTrack,
+ setCurrentTrack,
+ splashIndex,
+ setSplashIndex,
+ cycleSplash,
+ playClickSound,
+ playBackSound,
+ playSfx,
+ tracks: TRACKS,
+ splashes: SPLASHES,
+ };
+}
diff --git a/src/hooks/useDiscordRPC.ts b/src/hooks/useDiscordRPC.ts
new file mode 100644
index 0000000..e5ace3f
--- /dev/null
+++ b/src/hooks/useDiscordRPC.ts
@@ -0,0 +1,62 @@
+import { useEffect } from "react";
+import RpcService from "../services/RpcService";
+
+interface DiscordRPCProps {
+ rpcEnabled: boolean;
+ showIntro: boolean;
+ username: string;
+ profile: string;
+ activeView: string;
+ isGameRunning: boolean;
+ isWindowVisible: boolean;
+ downloadProgress: number | null;
+ downloadingId: string | null;
+ editions: any[];
+}
+
+export function useDiscordRPC({
+ rpcEnabled,
+ showIntro,
+ username,
+ profile,
+ activeView,
+ isGameRunning,
+ isWindowVisible,
+ downloadProgress,
+ downloadingId,
+ editions,
+}: DiscordRPCProps) {
+ useEffect(() => {
+ const updateRPC = async () => {
+ if (!rpcEnabled || showIntro || !username) return;
+
+ if (!isWindowVisible && !isGameRunning && downloadProgress === null) return;
+
+ const version = editions.find((e) => e.id === profile);
+ const versionName = version ? version.name : "Unknown Version";
+ let details = "In Menus";
+ let state = isGameRunning ? `Playing as ${username}` : `Logged in as ${username}`;
+
+ if (isGameRunning) {
+ details = `Playing ${versionName}`;
+ } else if (downloadProgress !== null) {
+ const downloadingName = editions.find((e) => e.id === downloadingId)?.name || "Game Files";
+ details = `Downloading ${downloadingName} (${downloadProgress.toFixed(0)}%)`;
+ } else {
+ const tabNames: Record = {
+ main: "Main Menu",
+ versions: "Selecting Version",
+ settings: "In Settings",
+ themes: "Browsing Themes",
+ skins: "Browsing Skins",
+ workshop: "Browsing Workshop",
+ };
+ details = tabNames[activeView] || "In Menus";
+ }
+
+ await RpcService.updateActivity(details, state, isGameRunning);
+ };
+
+ updateRPC();
+ }, [rpcEnabled, showIntro, username, profile, activeView, isGameRunning, isWindowVisible, Math.floor(downloadProgress || 0), downloadingId, editions]);
+}
diff --git a/src/hooks/useGameInstances.ts b/src/hooks/useGameInstances.ts
deleted file mode 100644
index 328a6f0..0000000
--- a/src/hooks/useGameInstances.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useState, useEffect } from 'react';
-import { listen } from "@tauri-apps/api/event";
-import { TauriService } from '../services/tauri';
-import { InstalledStatus, McNotification } from '../types';
-
-export const useGameInstances = (
- playSfx: (name: string, multiplier?: number) => void,
- setMcNotif: (notif: McNotification | null) => void
-) => {
- const [installingInstance, setInstallingInstance] = useState(null);
- const [downloadProgress, setDownloadProgress] = useState(0);
- const [installedStatus, setInstalledStatus] = useState({
- vanilla_tu19: false,
- vanilla_tu24: false,
- });
-
- const updateAllStatus = async () => {
- const v19 = await TauriService.checkGameInstalled("vanilla_tu19");
- const v24 = await TauriService.checkGameInstalled("vanilla_tu24");
- setInstalledStatus({ vanilla_tu19: v19, vanilla_tu24: v24 });
- };
-
- const executeInstall = async (id: string, url: string) => {
- setInstallingInstance(id);
- setDownloadProgress(0);
- try {
- await TauriService.downloadAndInstall(url, id);
- setMcNotif({ t: "Success!", m: "Ready to play." });
- playSfx('orb.ogg');
- setTimeout(() => setMcNotif(null), 4000);
- updateAllStatus();
- } catch (e) {
- console.error(e);
- alert("Error during installation: " + e);
- }
- setInstallingInstance(null);
- };
-
- useEffect(() => {
- updateAllStatus();
- const unlisten = listen("download-progress", (e) =>
- setDownloadProgress(Math.round(e.payload))
- );
- return () => {
- unlisten.then((f) => f());
- };
- }, []);
-
- return {
- installedStatus,
- installingInstance,
- downloadProgress,
- executeInstall,
- updateAllStatus,
- };
-};
diff --git a/src/hooks/useGameManager.ts b/src/hooks/useGameManager.ts
new file mode 100644
index 0000000..64c20e9
--- /dev/null
+++ b/src/hooks/useGameManager.ts
@@ -0,0 +1,225 @@
+import { useState, useEffect, useCallback, useMemo } from "react";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import { TauriService } from "../services/TauriService";
+
+const appWindow = getCurrentWindow();
+
+const BASE_EDITIONS = [
+ {
+ id: "revelations_edition",
+ name: "Revelations",
+ desc: "Enhanced LCE with uncapped FPS, graphics fixes, hardcore hearts, and dedicated server security. Features LAN multiplayer, split-screen, and keyboard & mouse support.",
+ url: "https://github.com/itsRevela/MinecraftConsoles/releases/download/Nightly/LCREWindows64.zip",
+ titleImage: "/images/minecraft_title_revelations.png",
+ credits: {
+ developer: "itsRevela",
+ platform: "github",
+ url: "https://github.com/itsRevela"
+ }
+ },
+ {
+ id: "360revived",
+ name: "360 Revived",
+ desc: "PC port of Xbox 360 Edition TU19 with desktop optimizations. Features keyboard & mouse, fullscreen, LAN multiplayer, dedicated server, and split-screen support.",
+ url: "https://github.com/BluTac10/360Revived/releases/download/nightly/LCEWindows64.zip",
+ titleImage: "/images/minecraft_title_360revived.png",
+ credits: {
+ developer: "BluTac10",
+ platform: "github",
+ url: "https://github.com/BluTac10"
+ }
+ },
+ {
+ id: "legacy_evolved",
+ name: "Legacy Evolved",
+ desc: "Backports newer title updates to LCE TU19 base. Currently porting TU25 (98% complete) and TU31 (76% complete).",
+ url: "https://codeberg.org/piebot/LegacyEvolved/releases/download/nightly/LCEWindows64.zip",
+ titleImage: "/images/minecraft_title_LegacyEvolved.png",
+ credits: {
+ developer: "piebot",
+ platform: "codeberg",
+ url: "https://codeberg.org/piebot"
+ }
+ },
+ {
+ id: "vanilla_tu19",
+ name: "Title Update 19",
+ desc: "Minecraft LCE v1.6.0560.0 with compilation fixes. Base version for modding with keyboard & mouse, fullscreen, LAN multiplayer, and dedicated server support.",
+ url: "https://github.com/smartcmd/MinecraftConsoles/releases/download/nightly/LCEWindows64.zip",
+ titleImage: "/images/minecraft_title_tu19.png",
+ credits: {
+ developer: "smartcmd",
+ platform: "github",
+ url: "https://github.com/smartcmd"
+ }
+ },
+ {
+ id: "lmrp_placeholder",
+ name: "LMRP (Coming Soon)",
+ desc: "Legacy Minecraft Restoration Project - Classic mini-games and nostalgic gameplay. Stay tuned for updates!",
+ url: "",
+ titleImage: "/images/minecraft_title_lmrp.png",
+ credits: {
+ developer: "LMRP Team",
+ platform: "github",
+ url: "https://github.com/LMRP-Project"
+ }
+ },
+];
+
+const PARTNERSHIP_SERVERS = [
+ {
+ name: "Kowhaifans Clubhouse",
+ ip: "kowhaifan.ddns.net",
+ port: 25565,
+ },
+];
+
+interface GameManagerProps {
+ profile: string;
+ setProfile: (id: string) => void;
+ customEditions: any[];
+ setCustomEditions: (editions: any[]) => void;
+ keepLauncherOpen: boolean;
+}
+
+export function useGameManager({ profile, setProfile, customEditions, setCustomEditions, keepLauncherOpen }: GameManagerProps) {
+ const [installs, setInstalls] = useState([]);
+ const [isGameRunning, setIsGameRunning] = useState(false);
+ const [downloadProgress, setDownloadProgress] = useState(null);
+ const [downloadingId, setDownloadingId] = useState(null);
+ const [isRunnerDownloading, setIsRunnerDownloading] = useState(false);
+ const [runnerDownloadProgress, setRunnerDownloadProgress] = useState(null);
+ const [error, setError] = useState(null);
+
+ const editions = useMemo(() => [...BASE_EDITIONS, ...customEditions], [customEditions]);
+
+ const checkInstalls = useCallback(async () => {
+ const results = await Promise.all(
+ editions.map(async (e) => {
+ const isInstalled = await TauriService.checkGameInstalled(e.id);
+ return isInstalled ? e.id : null;
+ }),
+ );
+ setInstalls(results.filter((id): id is string => id !== null));
+ }, [editions]);
+
+ useEffect(() => {
+ checkInstalls();
+ const unlistenDownload = TauriService.onDownloadProgress((p) => setDownloadProgress(p));
+ const unlistenRunner = TauriService.onRunnerDownloadProgress((p) => setRunnerDownloadProgress(p));
+ return () => {
+ unlistenDownload.then((u) => u());
+ unlistenRunner.then((u) => u());
+ };
+ }, [customEditions, checkInstalls]);
+
+ const downloadRunner = useCallback(async (name: string, url: string) => {
+ if (isRunnerDownloading) return;
+ setIsRunnerDownloading(true);
+ setRunnerDownloadProgress(0);
+ setError(null);
+ try {
+ await TauriService.downloadRunner(name, url);
+ setRunnerDownloadProgress(null);
+ } catch (e: any) {
+ console.error(e);
+ setError(typeof e === 'string' ? e : e.message || "Failed to download runner");
+ } finally {
+ setIsRunnerDownloading(false);
+ }
+ }, [isRunnerDownloading]);
+
+ const toggleInstall = useCallback(async (id: string) => {
+ if (downloadingId) return;
+ const edition = editions.find((e) => e.id === id);
+ if (!edition) return;
+ setError(null);
+ try {
+ setDownloadingId(id);
+ setDownloadProgress(0);
+ await TauriService.downloadAndInstall(edition.url, id);
+ await TauriService.syncDlc(id);
+ await checkInstalls();
+ setProfile(id);
+ setDownloadProgress(null);
+ setDownloadingId(null);
+ } catch (e: any) {
+ console.error(e);
+ setError(typeof e === 'string' ? e : e.message || "Failed to install version");
+ setDownloadProgress(null);
+ setDownloadingId(null);
+ }
+ }, [downloadingId, editions, checkInstalls, setProfile]);
+
+ const handleUninstall = useCallback(async (id: string) => {
+ await TauriService.deleteInstance(id);
+ await checkInstalls();
+ }, [checkInstalls]);
+
+ const handleLaunch = useCallback(async () => {
+ if (isGameRunning) return;
+ setError(null);
+ setIsGameRunning(true);
+ try {
+ if (!keepLauncherOpen) {
+ await appWindow.hide();
+ }
+ await TauriService.launchGame(profile, PARTNERSHIP_SERVERS);
+ } catch (e: any) {
+ console.error(e);
+ setError(typeof e === 'string' ? e : e.message || "Failed to launch game");
+ } finally {
+ setIsGameRunning(false);
+ await appWindow.show();
+ await appWindow.unminimize();
+ await appWindow.setFocus();
+ }
+ }, [isGameRunning, profile, keepLauncherOpen]);
+
+ const stopGame = useCallback(async () => {
+ try {
+ await TauriService.stopGame(profile);
+ setIsGameRunning(false);
+ } catch (e) {
+ console.error(e);
+ }
+ }, [profile]);
+
+ const addCustomEdition = useCallback((edition: { name: string; desc: string; url: string }) => {
+ const id = `custom_${Date.now()}`;
+ const newEdition = { ...edition, id, titleImage: "/images/minecraft_title_tucustom.png" };
+ setCustomEditions([...customEditions, newEdition]);
+ return id;
+ }, [customEditions, setCustomEditions]);
+
+ const deleteCustomEdition = useCallback((id: string) => {
+ setCustomEditions(customEditions.filter((e) => e.id !== id));
+ TauriService.deleteInstance(id).catch(console.error);
+ }, [customEditions, setCustomEditions]);
+
+ const updateCustomEdition = useCallback((id: string, updated: { name: string; desc: string; url: string }) => {
+ setCustomEditions(customEditions.map((e) => e.id === id ? { ...e, ...updated } : e));
+ }, [customEditions, setCustomEditions]);
+
+ return {
+ installs,
+ isGameRunning,
+ downloadProgress,
+ downloadingId,
+ isRunnerDownloading,
+ runnerDownloadProgress,
+ error,
+ setError,
+ editions,
+ toggleInstall,
+ handleUninstall,
+ handleLaunch,
+ stopGame,
+ addCustomEdition,
+ deleteCustomEdition,
+ updateCustomEdition,
+ downloadRunner,
+ checkInstalls,
+ };
+}
diff --git a/src/hooks/useGamepad.ts b/src/hooks/useGamepad.ts
new file mode 100644
index 0000000..6c6a196
--- /dev/null
+++ b/src/hooks/useGamepad.ts
@@ -0,0 +1,128 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+
+export interface UseGamepadProps {
+ playSfx: (file: string) => void;
+ isWindowVisible: boolean;
+}
+
+export const useGamepad = ({ playSfx, isWindowVisible }: UseGamepadProps) => {
+ const [connected, setConnected] = useState(false);
+ const requestRef = useRef(undefined);
+ const focusedRef = useRef(document.hasFocus());
+ const lastButtons = useRef>({});
+ const lastAxes = useRef>({});
+ const stateRef = useRef({ playSfx });
+
+ useEffect(() => {
+ const onFocus = () => { focusedRef.current = true; };
+ const onBlur = () => {
+ focusedRef.current = false;
+ lastButtons.current = {};
+ lastAxes.current = {};
+ };
+ window.addEventListener('focus', onFocus);
+ window.addEventListener('blur', onBlur);
+ return () => {
+ window.removeEventListener('focus', onFocus);
+ window.removeEventListener('blur', onBlur);
+ };
+ }, []);
+
+ useEffect(() => {
+ stateRef.current = { playSfx };
+ }, [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 update = useCallback(() => {
+ if (!focusedRef.current) {
+ requestRef.current = requestAnimationFrame(update);
+ return;
+ }
+ try {
+ const gamepads = navigator.getGamepads ? navigator.getGamepads() : null;
+ if (gamepads) {
+ for (const gp of gamepads) {
+ if (!gp) continue;
+ const btnVal = (i: number): number => {
+ const btn = gp.buttons[i];
+ if (!btn) return 0;
+ return typeof btn === "object" ? btn.value : (btn as any) ?? 0;
+ };
+ const justPressed = (i: number) => btnVal(i) > 0.5 && !lastButtons.current[i];
+
+ if (justPressed(1)) dispatchKey('Enter');
+ if (justPressed(2)) dispatchKey('Escape');
+ if (justPressed(4)) dispatchKey('Tab', true);
+ if (justPressed(5)) dispatchKey('Tab');
+
+ const newButtons: Record = {};
+ gp.buttons.forEach((btn, i) => {
+ newButtons[i] = (typeof btn === "object" ? btn.value : btn) > 0.5;
+ });
+ lastButtons.current = newButtons;
+
+ const deadzone = 0.5;
+ const axisY = gp.axes[2] ?? 0;
+ const prevY = lastAxes.current[2] ?? 0;
+ if (Math.abs(axisY) > deadzone && Math.abs(prevY) <= deadzone) {
+ dispatchKey(axisY < 0 ? 'ArrowDown' : 'ArrowUp');
+ }
+ lastAxes.current[2] = axisY;
+
+ const axisX = gp.axes[1] ?? 0;
+ const prevX = lastAxes.current[1] ?? 0;
+ if (Math.abs(axisX) > deadzone && Math.abs(prevX) <= deadzone) {
+ dispatchKey(axisX > 0 ? 'ArrowRight' : 'ArrowLeft');
+ }
+ lastAxes.current[1] = axisX;
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ requestRef.current = requestAnimationFrame(update);
+ }, []);
+
+ useEffect(() => {
+ const handleConnect = () => setConnected(true);
+ const handleDisconnect = () => {
+ const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
+ const hasGamepads = Array.from(gamepads).some(gp => gp !== null);
+ setConnected(hasGamepads);
+ };
+
+ window.addEventListener("gamepadconnected", handleConnect);
+ window.addEventListener("gamepaddisconnected", handleDisconnect);
+
+ const initialGamepads = navigator.getGamepads ? navigator.getGamepads() : [];
+ if (Array.from(initialGamepads).some(gp => gp !== null)) {
+ setConnected(true);
+ }
+
+ return () => {
+ window.removeEventListener("gamepadconnected", handleConnect);
+ window.removeEventListener("gamepaddisconnected", handleDisconnect);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (connected && isWindowVisible) {
+ requestRef.current = requestAnimationFrame(update);
+ } else if (requestRef.current) {
+ cancelAnimationFrame(requestRef.current);
+ }
+ return () => {
+ if (requestRef.current) cancelAnimationFrame(requestRef.current);
+ };
+ }, [connected, update, isWindowVisible]);
+
+ return { connected };
+};
\ No newline at end of file
diff --git a/src/hooks/useLauncher.ts b/src/hooks/useLauncher.ts
deleted file mode 100644
index 6367e7a..0000000
--- a/src/hooks/useLauncher.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useState } from 'react';
-import { TauriService } from '../services/tauri';
-
-export const useLauncher = (
- selectedInstance: string,
- musicRef: React.RefObject,
- isMuted: boolean,
- musicVol: number,
- playRandomMusic: () => void,
- playSfx: (name: string, multiplier?: number) => void
-) => {
- const [isRunning, setIsRunning] = useState(false);
-
- const fadeAndLaunch = async () => {
- playSfx('levelup.ogg', 0.4);
- setIsRunning(true);
- if (musicRef.current && !isMuted) {
- const startVol = musicRef.current.volume;
- const steps = 20;
- let currentStep = 0;
- const fade = setInterval(() => {
- currentStep++;
- if (musicRef.current) {
- musicRef.current.volume = Math.max(0, startVol * (1 - currentStep / steps));
- }
- if (currentStep >= steps) {
- clearInterval(fade);
- if (musicRef.current) musicRef.current.pause();
- }
- }, 50);
- }
- setTimeout(async () => {
- try {
- await TauriService.launchGame(selectedInstance);
- } catch (e) {
- alert(`Failed to launch game: ${e}`);
- } finally {
- setIsRunning(false);
- if (musicRef.current) {
- musicRef.current.volume = isMuted ? 0 : musicVol;
- playRandomMusic();
- }
- }
- }, 1500);
- };
-
- return {
- isRunning,
- fadeAndLaunch,
- };
-};
diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts
new file mode 100644
index 0000000..dfbd1db
--- /dev/null
+++ b/src/hooks/useLocalStorage.ts
@@ -0,0 +1,24 @@
+import { useState, useCallback } from 'react';
+
+export function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ return initialValue;
+ }
+ });
+
+ const setValue = useCallback((value: T | ((val: T) => T)) => {
+ try {
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
+ setStoredValue(valueToStore);
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
+ } catch (error) {
+ console.log(error);
+ }
+ }, [key, storedValue]);
+
+ return [storedValue, setValue];
+}
\ No newline at end of file
diff --git a/src/hooks/usePlatform.ts b/src/hooks/usePlatform.ts
new file mode 100644
index 0000000..aa1471b
--- /dev/null
+++ b/src/hooks/usePlatform.ts
@@ -0,0 +1,19 @@
+import { useMemo } from 'react';
+
+
+export function usePlatform() {
+ const platform = useMemo(() => {
+ if (typeof window === 'undefined') return { isLinux: false, isMac: false, isWindows: 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 };
+ }, []);
+
+ return platform;
+}
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
deleted file mode 100644
index 6b0b81b..0000000
--- a/src/hooks/useSettings.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useState, useEffect } from 'react';
-
-export const useSettings = () => {
- const [musicVol, setMusicVol] = useState(parseFloat(localStorage.getItem("musicVol") || "0.4"));
- const [sfxVol, setSfxVol] = useState(parseFloat(localStorage.getItem("sfxVol") || "0.7"));
- const [isMuted, setIsMuted] = useState(localStorage.getItem("isMuted") === "true");
-
- useEffect(() => {
- localStorage.setItem("musicVol", musicVol.toString());
- localStorage.setItem("sfxVol", sfxVol.toString());
- localStorage.setItem("isMuted", isMuted.toString());
- }, [musicVol, sfxVol, isMuted]);
-
- return {
- musicVol,
- setMusicVol,
- sfxVol,
- setSfxVol,
- isMuted,
- setIsMuted,
- };
-};
diff --git a/src/hooks/useSkinSync.ts b/src/hooks/useSkinSync.ts
new file mode 100644
index 0000000..8f20e91
--- /dev/null
+++ b/src/hooks/useSkinSync.ts
@@ -0,0 +1,37 @@
+import { useState, useEffect } from "react";
+import { useLocalStorage } from "./useLocalStorage";
+
+export function useSkinSync() {
+ const [skinUrl, setSkinUrl] = useLocalStorage("lce-skin", "/images/Default.png");
+ const [skinBase64, setSkinBase64] = useState(null);
+
+ useEffect(() => {
+ const syncSkin = async () => {
+ if (!skinUrl) return;
+ try {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.onload = () => {
+ const cvs = document.createElement("canvas");
+ cvs.width = 64;
+ cvs.height = 32;
+ const ctx = cvs.getContext("2d");
+ if (ctx) {
+ ctx.drawImage(img, 0, 0, 64, 32, 0, 0, 64, 32);
+ setSkinBase64(cvs.toDataURL("image/png"));
+ }
+ };
+ img.src = skinUrl;
+ } catch (e) {
+ console.error("Skin conversion failed:", e);
+ }
+ };
+ syncSkin();
+ }, [skinUrl]);
+
+ return {
+ skinUrl,
+ setSkinUrl,
+ skinBase64,
+ };
+}
diff --git a/src/hooks/useUpdateCheck.ts b/src/hooks/useUpdateCheck.ts
new file mode 100644
index 0000000..f6fe239
--- /dev/null
+++ b/src/hooks/useUpdateCheck.ts
@@ -0,0 +1,49 @@
+import { useState, useEffect, useCallback } from "react";
+import pkg from "../../package.json";
+
+const CURRENT_VERSION = pkg.version;
+const REPO_URL = "https://api.github.com/repos/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher/releases/latest";
+
+function isNewerVersion(latest: string, current: string): boolean {
+ const latestParts = latest.split('.').map(Number);
+ const currentParts = current.split('.').map(Number);
+
+ for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
+ const latestPart = latestParts[i] || 0;
+ const currentPart = currentParts[i] || 0;
+
+ if (latestPart > currentPart) return true;
+ if (latestPart < currentPart) return false;
+ }
+
+ return false;
+}
+
+export function useUpdateCheck() {
+ const [updateMessage, setUpdateMessage] = useState(null);
+
+ const checkUpdates = useCallback(async () => {
+ try {
+ const response = await fetch(REPO_URL);
+ if (!response.ok) return;
+
+ const data = await response.json();
+ const latestVersion = data.tag_name.replace(/^v/, '');
+
+ if (isNewerVersion(latestVersion, CURRENT_VERSION)) {
+ setUpdateMessage(`Version ${data.tag_name} is now available!`);
+ }
+ } catch (e) {
+ console.error("Failed to check for updates:", e);
+ }
+ }, []);
+
+ useEffect(() => {
+ checkUpdates();
+ }, [checkUpdates]);
+
+ return {
+ updateMessage,
+ clearUpdateMessage: () => setUpdateMessage(null),
+ };
+}
diff --git a/src/index.css b/src/index.css
deleted file mode 100644
index 85bda6d..0000000
--- a/src/index.css
+++ /dev/null
@@ -1,205 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-/* Fonts and Globals */
-@font-face {
- font-family: 'Minecraft';
- src: url('/fonts/Mojangles.ttf') format('truetype');
-}
-
-* {
- border-radius: 0 !important;
- font-family: 'Minecraft', sans-serif !important;
- image-rendering: pixelated;
- font-weight: normal !important;
-}
-
-body {
- margin: 0;
-}
-
-main {
- background: url('/images/dirt-background.png') repeat !important;
- background-size: 2000px !important;
- position: relative;
-}
-
-main::before {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 0;
-}
-
-.no-scrollbar::-webkit-scrollbar {
- display: none;
-}
-
-.no-scrollbar {
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
-
-.sidebar-progress {
- background: #2a2a2a;
- border-top: 4px solid #000;
- padding: 20px;
- position: relative;
- box-shadow: inset 0 4px #444;
-}
-
-/* Legacy UI Components */
-.legacy-btn {
- @apply transition-all duration-75 flex items-center justify-center;
- background: #bebebe;
- border: 4px solid #000 !important;
- box-shadow: inset 4px 4px #ffffff, inset -4px -4px #555555 !important;
- color: #3e3e3e !important;
- cursor: pointer;
-}
-
-.legacy-btn:hover:not(:disabled) {
- background: #d0d0d0;
- outline: 4px solid #fff;
- outline-offset: -8px;
-}
-
-.legacy-btn:active:not(:disabled) {
- box-shadow: inset -4px -4px #ffffff, inset 4px 4px #555555 !important;
-}
-
-.legacy-btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- filter: grayscale(1);
-}
-
-.legacy-select {
- appearance: none;
- -webkit-appearance: none;
- -moz-appearance: none;
- background: #bebebe url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233e3e3e' stroke-width='3' stroke-linecap='square' stroke-linejoin='miter'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E") no-repeat right 12px center;
- border: 4px solid #000 !important;
- box-shadow: inset 4px 4px #ffffff, inset -4px -4px #555555 !important;
- color: #3e3e3e !important;
- cursor: pointer;
- padding-right: 48px;
-}
-
-.legacy-select:focus {
- outline: 4px solid #fff;
- outline-offset: -8px;
-}
-
-.mc-progress-container {
- background: #313131 !important;
- border: 4px solid #000 !important;
- height: 32px !important;
- position: relative;
- display: flex;
- align-items: center;
- padding: 0 4px;
-}
-
-.mc-progress-bar {
- background: #55FF55 !important;
- height: 16px !important;
- box-shadow: inset -2px -2px #00AA00, inset 2px 2px #AAFFAA !important;
-}
-
-.mc-progress-text {
- position: absolute;
- width: 100%;
- text-align: center;
- color: #fff;
- text-shadow: 2px 2px #000;
- font-size: 14px;
- z-index: 10;
- pointer-events: none;
-}
-
-.active-tab {
- background: #388e3c !important;
- box-shadow: inset 4px 4px #55ff55, inset -4px -4px #1a4d0a !important;
- color: #fff !important;
-}
-
-.reinstall-btn,
-.confirm-red-btn,
-.cancel-download-btn {
- background: #ff5555 !important;
- box-shadow: inset 4px 4px #ffaaaa, inset -4px -4px #aa0000 !important;
- color: #fff !important;
-}
-
-.mc-range {
- appearance: none;
- -webkit-appearance: none;
- background: #000;
- height: 12px;
- border: 3px solid #555;
- width: 100%;
-}
-
-.mc-range::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 16px;
- height: 24px;
- background: #bebebe;
- border: 3px solid #000;
- box-shadow: inset 2px 2px #fff, inset -2px -2px #555;
- cursor: pointer;
-}
-
-.social-btn {
- background: transparent;
- border: 4px solid #000;
- box-shadow: inset 4px 4px #555, inset -4px -4px #111;
- width: 64px;
- height: 64px;
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: pointer;
- transition: transform 0.1s;
-}
-
-.social-btn:hover {
- transform: scale(1.1);
-}
-
-.btn-discord {
- background: #5865F2 !important;
- box-shadow: inset 4px 4px #8ea1e1, inset -4px -4px #313338 !important;
-}
-
-.btn-github {
- background: #333 !important;
- box-shadow: inset 4px 4px #666, inset -4px -4px #000 !important;
-}
-
-.btn-reddit {
- background: #FF4500 !important;
- box-shadow: inset 4px 4px #ff8b60, inset -4px -4px #942700 !important;
-}
-
-.splash-text {
- color: #ffff00;
- text-shadow: 4px 4px #3f3f00;
- white-space: nowrap;
- pointer-events: none;
- z-index: 20;
- transform: rotate(-20deg);
- animation: splash-pulse 0.5s infinite alternate;
- transform-origin: center;
-}
-
-@keyframes splash-pulse {
- from {
- transform: rotate(-20deg) scale(1);
- }
- to {
- transform: rotate(-20deg) scale(1.05);
- }
-}
diff --git a/src/main.tsx b/src/main.tsx
index 02e3004..afe1914 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,16 +1,15 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import App from "./App";
-import './index.css';
-
-const rootElement = document.getElementById("root");
-
-if (rootElement) {
- ReactDOM.createRoot(rootElement).render(
-
-
-
- );
-} else {
- console.error("ERREUR FATALE : La div avec l'id 'root' est introuvable dans index.html");
-}
\ No newline at end of file
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "tauri-plugin-gamepad-api";
+import App from "./pages/App";
+import "./css/index.css";
+import "./css/App.css";
+import { LauncherProvider } from "./context/LauncherContext";
+// RpcService is now managed by LauncherProvider context
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+
+
+
+);
\ No newline at end of file
diff --git a/src/pages/App.tsx b/src/pages/App.tsx
new file mode 100644
index 0000000..61dbbb3
--- /dev/null
+++ b/src/pages/App.tsx
@@ -0,0 +1,369 @@
+import { useEffect, useState } from "react";
+import { motion, AnimatePresence, MotionConfig } from "framer-motion";
+import HomeView from "../components/views/HomeView";
+import SettingsView from "../components/views/SettingsView";
+import VersionsView from "../components/views/VersionsView";
+import ThemesView from "../components/views/ThemesView";
+import SkinsView from "../components/views/SkinsView";
+import WorkshopView from "../components/views/WorkshopView";
+import SetupView from "../components/views/SetupView";
+import SkinViewer from "../components/common/SkinViewer";
+import TeamModal from "../components/modals/TeamModal";
+import SpecialThanksModal from "../components/modals/SpecialThanksModal";
+import PanoramaBackground from "../components/common/PanoramaBackground";
+import { ClickParticles } from "../components/common/ClickParticles";
+import { AppHeader } from "../components/layout/AppHeader";
+import { DownloadOverlay } from "../components/layout/DownloadOverlay";
+import { AchievementToast } from "../components/common/AchievementToast";
+import { useUI, useConfig, useAudio, useGame, useSkin } from "../context/LauncherContext";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import { TauriService } from "../services/TauriService";
+
+const appWindow = getCurrentWindow();
+
+export default function App() {
+ const {
+ showIntro, setShowIntro, logoAnimDone, setLogoAnimDone,
+ activeView, setActiveView, isUiHidden, setIsUiHidden,
+ showCredits, setShowCredits, showSpecialThanks, setShowSpecialThanks, focusSection,
+ onNavigateToMenu, updateMessage, clearUpdateMessage
+ } = useUI();
+
+ const config = useConfig();
+ const audio = useAudio();
+ const game = useGame();
+ const { skinUrl, setSkinUrl } = useSkin();
+
+ const [showSetup, setShowSetup] = useState(true);
+ const [displayIsDay, setDisplayIsDay] = useState(config.isDayTime);
+
+ useEffect(() => {
+ setDisplayIsDay(config.isDayTime);
+ }, [config.isDayTime]);
+
+ const selectedEdition = game.editions.find((e: any) => e.id === config.profile);
+ const selectedVersionName = selectedEdition?.name || "";
+
+ const titleImage = selectedEdition?.titleImage || "/images/MenuTitle.png";
+
+ useEffect(() => {
+ if (config.isLoaded) {
+ // Check localStorage directly to ensure accurate setup state
+ const setupCompleted = localStorage.getItem('lce-setup-completed') === 'true';
+ setShowSetup(!setupCompleted);
+ }
+ }, [config.isLoaded]);
+
+ useEffect(() => {
+ appWindow.show();
+ // Only start intro timing if setup is not shown
+ if (!showSetup) {
+ setTimeout(() => setShowIntro(false), 2400);
+ setTimeout(() => setLogoAnimDone(true), 3400);
+ } else if (showSetup) {
+ // Skip intro entirely if setup is shown
+ setShowIntro(false);
+ setLogoAnimDone(true);
+ }
+ }, [showSetup]);
+
+ useEffect(() => {
+ const handleContextMenu = (e: MouseEvent) => e.preventDefault();
+ document.addEventListener("contextmenu", handleContextMenu);
+ return () => document.removeEventListener("contextmenu", handleContextMenu);
+ }, []);
+
+ const uiFade = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+ transition: { duration: config.animationsEnabled ? 0.5 : 0 }
+ };
+
+ const backgroundFade = {
+ initial: { opacity: 0 },
+ animate: { opacity: 1 },
+ exit: { opacity: 0 },
+ transition: { duration: config.animationsEnabled ? 0.8 : 0 }
+ };
+
+ return (
+
+
+
+
+
+ {config.vfxEnabled &&
}
+
+
+ {showCredits && (
+ setShowCredits(false)}
+ playClickSound={audio.playClickSound}
+ playSfx={audio.playSfx}
+ />
+ )}
+ {showSpecialThanks && (
+ setShowSpecialThanks(false)}
+ playClickSound={audio.playClickSound}
+ playSfx={audio.playSfx}
+ />
+ )}
+
+
+
+
+
+
+
game.setError(null)}
+ />
+
+ TauriService.openUrl("https://emerald-legacy-launcher.github.io/")}
+ title="Update Available!"
+ variant="update"
+ />
+
+
+ {showSetup ? (
+ {
+ setShowSetup(false);
+ setShowIntro(true);
+ }}
+ />
+ ) : showIntro ? (
+
+
+
+ ) : (
+
+
+ {logoAnimDone && }
+
+
+
+ {logoAnimDone && (
+ <>
+ {!config.legacyMode && (
+
+ {
+ audio.playClickSound();
+ setIsUiHidden(!isUiHidden);
+ }}
+ className="hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none"
+ >
+
+
+
+ )}
+
+ {!config.legacyMode && (
+
+
+ {displayIsDay ? "Day" : "Night"}
+
+ {
+ audio.playClickSound();
+ config.setIsDayTime(!config.isDayTime);
+ }}
+ className="hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none"
+ >
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ {logoAnimDone && (
+ <>
+
+
+ {audio.splashIndex === -1
+ ? `Welcome ${config.username}!`
+ : audio.splashes[audio.splashIndex]}
+
+
+ {activeView === "main" && titleImage === "/images/MenuTitle.png" && (
+
+ {selectedVersionName}
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ {activeView === "main" && (
+
+ )}
+
+
+
+
+ {activeView === "main" && (
+
+ )}
+ {activeView === "settings" && (
+
+ )}
+ {activeView === "versions" && (
+
+ )}
+ {activeView === "workshop" && (
+
+ )}
+ {activeView === "themes" && (
+
+ )}
+ {activeView === "skins" && (
+
+ )}
+
+
+
+
+
+
+ {logoAnimDone && (
+
+ Version: 1.0.0
+
+ Not affiliated with Mojang AB or Microsoft. "Minecraft" is a trademark of Mojang Synergies AB.
+
+
+ {useUI().connected && "CONTROLLER CONNECTED"}
+
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/services/RpcService.ts b/src/services/RpcService.ts
new file mode 100644
index 0000000..7ae7c2c
--- /dev/null
+++ b/src/services/RpcService.ts
@@ -0,0 +1,64 @@
+import { setActivity, start } from "tauri-plugin-drpc";
+import { Activity, ActivityType, Assets, Timestamps, Button } from "tauri-plugin-drpc/activity";
+
+class RPC {
+ private startTime: number = Date.now();
+ private initializationPromise: Promise | null = null;
+ private initialized: boolean = false;
+
+ public async StartRPC() {
+ if (this.initialized) return;
+ if (sessionStorage.getItem('lce_rpc_started') === 'true') {
+ this.initialized = true;
+ return;
+ }
+
+ if (this.initializationPromise) return this.initializationPromise;
+ this.initializationPromise = (async () => {
+ try {
+ await start("1482504445152460871");
+ sessionStorage.setItem('lce_rpc_started', 'true');
+ this.initialized = true;
+ } catch (e) {
+ console.error("Failed to start RPC:", e);
+ this.initializationPromise = null;
+ }
+ })();
+
+ return this.initializationPromise;
+ }
+
+ public async updateActivity(details: string, state: string, isPlaying: boolean = false) {
+ if (!this.initialized) {
+ await this.StartRPC();
+ if (!this.initialized) return;
+ }
+
+ const activity = new Activity();
+ activity.setDetails(details);
+ activity.setState(state);
+ activity.setActivity(ActivityType.Playing);
+
+ const assets = new Assets();
+ assets.setLargeImage("logo");
+ assets.setLargeText("Emerald Legacy");
+ assets.setSmallImage("app-icon");
+ assets.setSmallText(isPlaying ? "Playing" : "In Menus");
+ activity.setAssets(assets);
+
+ activity.setTimestamps(new Timestamps(this.startTime));
+
+ activity.setButton([
+ new Button("Discord", "https://discord.gg/RHGRUwpmVc"),
+ new Button("GitHub", "https://github.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher")
+ ]);
+
+ try {
+ await setActivity(activity);
+ } catch (e) {
+ console.error("Failed to set RPC activity:", e);
+ }
+ }
+}
+
+export default new RPC();
\ No newline at end of file
diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts
new file mode 100644
index 0000000..fa8dd28
--- /dev/null
+++ b/src/services/TauriService.ts
@@ -0,0 +1,173 @@
+import { invoke } from '@tauri-apps/api/core';
+import { listen } from '@tauri-apps/api/event';
+
+export interface McServer {
+ name: string;
+ ip: string;
+ port: number;
+}
+
+export interface SkinLibraryItem {
+ id: string;
+ name: string;
+ skinBase64: string;
+}
+
+export interface CustomEdition {
+ id: string;
+ name: string;
+ desc: string;
+ url: string;
+}
+
+export interface AppConfig {
+ username: string;
+ linuxRunner?: string;
+ skinBase64?: string;
+ skinLibrary?: SkinLibraryItem[];
+ themeStyleId?: string;
+ themePaletteId?: string;
+ appleSiliconPerformanceBoost?: boolean;
+ customEditions?: CustomEdition[];
+ profile?: string;
+ keepLauncherOpen?: boolean;
+ enableTrayIcon?: boolean;
+ animationsEnabled?: boolean;
+ vfxEnabled?: boolean;
+ rpcEnabled?: boolean;
+ musicVol?: number;
+ sfxVol?: number;
+ legacyMode?: boolean;
+}
+
+export interface ThemePalette {
+ id: string;
+ name: string;
+ colors: any;
+}
+
+export interface Runner {
+ id: string;
+ name: string;
+ path: string;
+ type: 'wine' | 'proton';
+}
+
+export interface MacOSSetupProgress {
+ stage: string;
+ message: string;
+ percent?: number;
+}
+
+export class TauriService {
+ static async saveConfig(config: AppConfig): Promise {
+ return invoke('save_config', { config });
+ }
+
+ static async loadConfig(): Promise {
+ return invoke('load_config');
+ }
+
+ static async getExternalPalettes(): Promise {
+ return invoke('get_external_palettes');
+ }
+
+ static async importTheme(): Promise {
+ return invoke('import_theme');
+ }
+
+ static async getAvailableRunners(): Promise {
+ return invoke('get_available_runners');
+ }
+
+ static async downloadRunner(name: string, url: string): Promise {
+ return invoke('download_runner', { name, url });
+ }
+
+ static async checkGameInstalled(instanceId: string): Promise {
+ return invoke('check_game_installed', { instanceId });
+ }
+
+ static async openInstanceFolder(instanceId: string): Promise {
+ return invoke('open_instance_folder', { instanceId });
+ }
+
+ static async deleteInstance(instanceId: string): Promise {
+ return invoke('delete_instance', { instanceId });
+ }
+
+ static async cancelDownload(): Promise {
+ return invoke('cancel_download');
+ }
+
+ static async setupMacosRuntime(): Promise {
+ return invoke('setup_macos_runtime');
+ }
+
+ static async downloadAndInstall(url: string, instanceId: string): Promise {
+ return invoke('download_and_install', { url, instanceId });
+ }
+
+ static async launchGame(instanceId: string, servers: McServer[]): Promise {
+ return invoke('launch_game', { instanceId, servers });
+ }
+
+ static async stopGame(instanceId: string): Promise {
+ return invoke('stop_game', { instanceId });
+ }
+
+ static async syncDlc(instanceId: string): Promise {
+ return invoke('sync_dlc', { instanceId });
+ }
+
+ static async updateTrayIcon(visible: boolean): Promise {
+ return invoke('update_tray_icon', { visible });
+ }
+
+ static onDownloadProgress(callback: (percent: number) => void) {
+ return listen('download-progress', (event) => callback(event.payload));
+ }
+
+ static onRunnerDownloadProgress(callback: (percent: number) => void) {
+ return listen('runner-download-progress', (event) => callback(event.payload));
+ }
+
+ static onMacosProgress(callback: (payload: MacOSSetupProgress) => void) {
+ return listen('macos-setup-progress', (event) => callback(event.payload));
+ }
+
+ static async openUrl(url: string): Promise {
+ return invoke('plugin:opener|open_url', { url });
+ }
+
+ static async restartLauncher(): Promise {
+ return invoke('restart_launcher');
+ }
+
+ static async checkMacOSRuntimeInstalled(): Promise {
+ return invoke('check_macos_runtime_installed');
+ }
+
+ static async checkMacOSRuntimeInstalledFast(): Promise {
+ return invoke('check_macos_runtime_installed_fast');
+ }
+
+ static async setupMacOSRuntimeOptimized(): Promise {
+ return invoke('setup_macos_runtime_optimized');
+ }
+
+ static async fetchSkin(username: string): Promise<[string, string]> {
+ return invoke('fetch_skin', { username });
+ }
+
+ static async pathExists(_path: string): Promise {
+ // Simple web implementation using fetch to check if path exists
+ try {
+ // This is a simplified check - in a real implementation you'd use the Tauri API
+ // For now, we'll just check common paths via heuristics
+ return false; // Placeholder - will be implemented properly if needed
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/src/services/audio.ts b/src/services/audio.ts
deleted file mode 100644
index 4d93c78..0000000
--- a/src/services/audio.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-let audioCtx: AudioContext | null = null;
-let sfxGain: GainNode | null = null;
-const buffers: Record = {};
-
-export const ensureAudio = async () => {
- let currentCtx = audioCtx;
- if (!currentCtx) {
- const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
- if (!AC) return null;
- const newCtx = new AC() as AudioContext;
- const newGain = newCtx.createGain();
- newGain.connect(newCtx.destination);
- audioCtx = newCtx;
- sfxGain = newGain;
- currentCtx = newCtx;
-
- const sounds = ['click.wav', 'orb.ogg', 'levelup.ogg', 'back.ogg', 'pop.wav', 'wood click.wav'];
- sounds.forEach((s) => {
- fetch(`/sounds/${s}`)
- .then((r) => r.arrayBuffer())
- .then((b) => newCtx.decodeAudioData(b))
- .then((buf) => {
- if (buf) buffers[s] = buf;
- });
- });
- }
- if (currentCtx.state === 'suspended') await currentCtx.resume();
- return { audioCtx, sfxGain, buffers };
-};
-
-export const playSfx = async (n: string, sfxVol: number, isMuted: boolean, multiplier: number = 1.0) => {
- const audioData = await ensureAudio();
- if (!audioData) return;
- const { audioCtx, sfxGain, buffers } = audioData;
-
- if (!audioCtx || !sfxGain || !buffers[n] || isMuted) return;
-
- const s = audioCtx.createBufferSource();
- s.buffer = buffers[n];
- const g = audioCtx.createGain();
- g.gain.value = sfxVol * multiplier;
- s.connect(g);
- g.connect(audioCtx.destination);
- s.start(0);
-};
diff --git a/src/services/tauri.ts b/src/services/tauri.ts
deleted file mode 100644
index f40c149..0000000
--- a/src/services/tauri.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { AppConfig, Runner } from "../types";
-
-export const TauriService = {
- loadConfig: () => invoke("load_config"),
- saveConfig: (config: AppConfig) => invoke("save_config", { config }),
- launchGame: (instanceId: string) => invoke("launch_game", { instanceId }),
- downloadAndInstall: (url: string, instanceId: string) => invoke("download_and_install", { url, instanceId }),
- checkGameInstalled: (instanceId: string) => invoke("check_game_installed", { instanceId }),
- getAvailableRunners: () => invoke("get_available_runners"),
- openInstanceFolder: (instanceId: string) => invoke("open_instance_folder", { instanceId }),
- cancelDownload: () => invoke("cancel_download"),
-};
diff --git a/src/services/unused.ts b/src/services/unused.ts
new file mode 100644
index 0000000..ada22b4
--- /dev/null
+++ b/src/services/unused.ts
@@ -0,0 +1 @@
+// empty :P
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
deleted file mode 100644
index 5500c77..0000000
--- a/src/types/index.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-export interface Runner {
- id: string;
- name: string;
- path: string;
- type: string;
-}
-
-export interface AppConfig {
- username: string;
- linuxRunner?: string;
-}
-
-export interface InstalledStatus {
- vanilla_tu19: boolean;
- vanilla_tu24: boolean;
- [key: string]: boolean;
-}
-
-export interface ReinstallModalData {
- id: string;
- url: string;
-}
-
-export interface McNotification {
- t: string;
- m: string;
-}
diff --git a/src/types/unused.ts b/src/types/unused.ts
new file mode 100644
index 0000000..ada22b4
--- /dev/null
+++ b/src/types/unused.ts
@@ -0,0 +1 @@
+// empty :P
\ No newline at end of file
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe..7d0ff9e 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/tailwind.config.js b/tailwind.config.js
deleted file mode 100644
index c1d2abe..0000000
--- a/tailwind.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- content: [
- "./index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
- theme: {
- extend: {},
- },
- plugins: [],
- }
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index f50b75c..9bdaa77 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,23 +1,25 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
-
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
-
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 42872c5..165a9ba 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -1,10 +1,10 @@
-{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true
- },
- "include": ["vite.config.ts"]
-}
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
index 4fc4018..5bfc4ed 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,18 +1,17 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-
-export default defineConfig({
- plugins: [react()],
- clearScreen: false,
- optimizeDeps: {
- entries: ['index.html'],
- exclude: ['bin', 'game', 'src-tauri']
- },
- server: {
- port: 1420,
- strictPort: true,
- watch: {
- ignored: ["**/src-tauri/**", "**/bin/**", "**/game/**", "**/*.app/**"],
- }
- },
-});
\ No newline at end of file
+import { defineConfig } from 'vite'
+import tailwindcss from '@tailwindcss/vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [
+ react(),
+ tailwindcss(),
+ ],
+ server: {
+ port: 1420,
+ strictPort: true,
+ watch: {
+ ignored: ["**/src-tauri/**"],
+ },
+ },
+})
\ No newline at end of file