import { useState, useEffect, useRef, memo } from "react"; import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService, PlaytimeResponse } from "../../services/TauriService"; import CustomTUModal from "../modals/CustomTUModal"; import SetUidModal from "../modals/SetUidModal"; import ImportWorldModal from "../modals/ImportWorldModal"; import PlaytimeModal from "../modals/PlaytimeModal"; import CustomizeModal from "../modals/CustomizeModal"; import DownloadDlcModal from "../modals/DownloadDlcModal"; import OptionsModal from "../modals/OptionsModal"; import { parseSchema } from "../../utils/argsSchema"; import { useUI, useConfig, useAudio, useGame, } from "../../context/LauncherContext"; import { ScreenshotImage } from "../common/ScreenshotImage"; import { usePlatform } from "../../hooks/usePlatform"; import type { Edition } from "../../types/edition"; import { HIDDEN_INSTANCE_URL } from "../../types/edition"; interface DeleteConfirmButtonProps { label: string; onClick: () => void; isDanger?: boolean; } const DeleteConfirmButton = memo(function DeleteConfirmButton({ label, onClick, isDanger = false, }: DeleteConfirmButtonProps) { const [isHovered, setIsHovered] = useState(false); return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${ isDanger ? "text-red-500" : "text-white" } ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`} style={{ backgroundImage: isHovered ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > {label} ); }); function formatPlaytime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m`; return seconds > 0 ? `${seconds}s` : ""; } const VersionsView = memo(function VersionsView() { const { t } = useTranslation(); const { setActiveView } = useUI(); const { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled, instanceLaunchArgs, setInstanceLaunchArgs, } = useConfig(); const { playPressSound, playBackSound } = useAudio(); const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingIds, downloadProgress, updatesAvailable, addToSteam, cycleBranch, customizations, updateCustomization, saveCustomPath, } = useGame(); const { isDayTime } = useConfig(); const { isAndroid } = usePlatform(); const visibleEditions = editions.filter( (e) => e.url !== HIDDEN_INSTANCE_URL || installedVersions.includes(e.instanceId), ); const [focusIndex, setFocusIndex] = useState(0); const [focusBtn, setFocusBtn] = useState(0); const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false); const [setUidTargetId, setSetUidTargetId] = useState(""); const [editingEdition, setEditingEdition] = useState(null); const [isImportWorldModalOpen, setIsImportWorldModalOpen] = useState(false); const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string; } | null>(null); const [isPlaytimeModalOpen, setIsPlaytimeModalOpen] = useState(false); const [playtimeTarget, setPlaytimeTarget] = useState<{ id: string; name: string; } | null>(null); const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false); const [customizeTarget, setCustomizeTarget] = useState(null); const [playtimeMap, setPlaytimeMap] = useState< Record >({}); const [initialPath, setInitialPath] = useState(""); const [hoveredBtn, setHoveredBtn] = useState<{ row: number; btn: string; } | null>(null); const [openMenuId, setOpenMenuId] = useState(null); const [deleteConfirmEdition, setDeleteConfirmEdition] = useState(null); const [isDlcModalOpen, setIsDlcModalOpen] = useState(false); const [dlcTargetEdition, setDlcTargetEdition] = useState( null, ); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [optionsTarget, setOptionsTarget] = useState<{ id: string; name: string; } | null>(null); const [argsSchemas, setArgsSchemas] = useState>({}); const containerRef = useRef(null); const listRef = useRef(null); const ITEM_COUNT = visibleEditions.length + 3; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.activeElement?.tagName === "INPUT") return; if ( isImportModalOpen || isSetUidModalOpen || isImportWorldModalOpen || isPlaytimeModalOpen || isCustomizeModalOpen || isDlcModalOpen || isOptionsModalOpen || deleteConfirmEdition ) { return; } if (e.key === "Escape" || e.key === "Backspace") { playBackSound(); setActiveView("main"); return; } if (e.key === "ArrowDown") { e.preventDefault(); setFocusIndex((prev) => (prev >= ITEM_COUNT - 1 ? 0 : prev + 1)); setFocusBtn(0); } else if (e.key === "ArrowUp") { e.preventDefault(); setFocusIndex((prev) => (prev <= 0 ? ITEM_COUNT - 1 : prev - 1)); setFocusBtn(0); } else if (e.key === "ArrowLeft") { e.preventDefault(); if (focusIndex < visibleEditions.length) { const edition = visibleEditions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1)); } } else if (e.key === "ArrowRight") { e.preventDefault(); if (focusIndex < visibleEditions.length) { const edition = visibleEditions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1)); } } else if (e.key === "Enter") { e.preventDefault(); if (focusIndex < visibleEditions.length) { const edition = visibleEditions[focusIndex]; const isInstalled = installedVersions.includes(edition.instanceId); const isDownloading = downloadingIds.includes(edition.instanceId); if (focusBtn === 0) { if (isInstalled) { playPressSound(); setOpenMenuId(openMenuId === edition.id ? null : edition.id); } else { if (!isDownloading) { playPressSound(); toggleInstall(edition.instanceId); } else { handleCancelDownload(edition.instanceId); } } } else if (focusBtn === 1 && !isInstalled) { playPressSound(); setOpenMenuId(openMenuId === edition.id ? null : edition.id); } else if (focusBtn === 2) { playPressSound(); cycleBranch(edition.id); } } else if (focusIndex === visibleEditions.length) { playPressSound(); setIsImportModalOpen(true); } else if (focusIndex === visibleEditions.length + 1) { playPressSound(); handleImportFolder(); } else { playBackSound(); setActiveView("main"); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ focusIndex, focusBtn, editions, installedVersions, downloadingIds, ITEM_COUNT, playPressSound, playBackSound, setSelectedProfile, setActiveView, toggleInstall, handleCancelDownload, addToSteam, isDayTime, isImportModalOpen, isSetUidModalOpen, isImportWorldModalOpen, isPlaytimeModalOpen, isCustomizeModalOpen, isDlcModalOpen, isOptionsModalOpen, deleteConfirmEdition, ]); useEffect(() => { if (focusIndex < visibleEditions.length && listRef.current) { const el = listRef.current.querySelector( `[data-index="${focusIndex}"]`, ) as HTMLElement; if (el) { el.scrollIntoView({ behavior: "smooth", block: "nearest" }); } } }, [focusIndex]); useEffect(() => { const fetchPlaytimes = async () => { const map: Record = {}; await Promise.all( installedVersions.map(async (id) => { try { map[id] = await TauriService.getPlaytime(id); } catch (e) { console.error(e); } }), ); setPlaytimeMap(map); }; fetchPlaytimes(); }, [installedVersions]); useEffect(() => { let cancelled = false; const checkSchemas = async () => { const map: Record = {}; await Promise.all( installedVersions.map(async (id) => { try { const schema = await TauriService.getInstanceArgsSchema(id); map[id] = !!schema && parseSchema(schema) !== null; } catch (e) { console.error(e); map[id] = false; } }), ); if (!cancelled) setArgsSchemas(map); }; checkSchemas(); return () => { cancelled = true; }; }, [installedVersions]); const handleEditionClick = (edition: Edition, index: number) => { const isInstalled = installedVersions.includes(edition.instanceId); if (isInstalled) { playPressSound(); setSelectedProfile(edition.instanceId); } setFocusIndex(index); }; const handleImportFolder = async () => { try { const folder = await TauriService.pickFolder(); if (folder) { setInitialPath(folder); setIsImportModalOpen(true); } } catch (e) { if (e !== "CANCELED") console.error(e); } }; const handleImportWorld = (instanceId: string) => { const edition = visibleEditions.find( (e: Edition) => e.instanceId === instanceId, ); setImportWorldTarget({ id: instanceId, name: edition?.name ?? instanceId }); setIsImportWorldModalOpen(true); }; return ( {t("versions.title")} {visibleEditions.map((edition: Edition, i: number) => { const isInstalled = installedVersions.includes( edition.instanceId, ); const hasAnyInstall = installedVersions.length > 0; const isSelected = hasAnyInstall && selectedProfile === edition.instanceId; const isFocused = focusIndex === i; const isCustom = edition.id.startsWith("custom_"); const isDownloading = downloadingIds.includes(edition.instanceId); const isComingSoon = edition.comingSoon; return ( !isComingSoon && setFocusIndex(i)} > {isDownloading ? ( {Math.floor(downloadProgress[edition.instanceId] || 0)}% ) : edition.logo ? ( edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( ) : ( ) ) : ( )} !isComingSoon && handleEditionClick(edition, i) } className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${ focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : "" } ${isComingSoon ? "cursor-not-allowed" : ""}`} > {edition.name} {isInstalled && ( { e.stopPropagation(); playPressSound(); setPlaytimeTarget({ id: edition.instanceId, name: edition.name, }); setIsPlaytimeModalOpen(true); }} className="flex items-center gap-1.5 px-2 py-1 bg-black/60 border border-[#555] hover:border-[#FFFF55] group transition-colors flex-shrink-0" title={t("versions.viewPlaytime")} > {playtimeMap[edition.instanceId] ? formatPlaytime( playtimeMap[edition.instanceId].totalSeconds, ) : ""} )} {edition.category && edition.category.map((cat: string) => ( {cat} ))} {isCustom && !edition.category && ( {t("versions.custom")} )} {edition.desc} {!isInstalled && ( { e.stopPropagation(); if (!isDownloading) { toggleInstall(edition.instanceId); } else { handleCancelDownload(edition.instanceId); } }} onMouseEnter={() => setHoveredBtn({ row: i, btn: "main" }) } onMouseLeave={() => setHoveredBtn(null)} className={`w-9 h-9 flex items-center justify-center ${ isDownloading ? "opacity-50" : "" }`} style={{ backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "main") || (focusIndex === i && focusBtn === 0) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > )} { e.stopPropagation(); playPressSound(); setOpenMenuId( openMenuId === edition.id ? null : edition.id, ); }} onMouseEnter={() => setHoveredBtn({ row: i, btn: "menu" }) } onMouseLeave={() => setHoveredBtn(null)} className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative" style={{ backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "menu") || (focusIndex === i && (focusBtn === 0 || focusBtn === 1)) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", filter: updatesAvailable?.[edition.instanceId] ? "drop-shadow(0 0 4px rgba(255,255,0,0.8))" : "none", }} > {openMenuId === edition.id && ( {updatesAvailable?.[edition.instanceId] && ( { e.stopPropagation(); playPressSound(); toggleInstall(edition.instanceId); setOpenMenuId(null); }} className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1" > {t("versions.updateAvailable")} )} {!isAndroid && !isInstalled && ( { e.stopPropagation(); playPressSound(); setOpenMenuId(null); try { const folder = await TauriService.pickFolder(); if (folder) { const entries = await TauriService.listDirectory(folder); if (entries.length > 0) { const dialog = document.createElement("div"); dialog.className = "fixed inset-0 bg-black/80 flex items-center justify-center z-50"; dialog.innerHTML = ` ${t("versions.invalidDirectory")} ${t("versions.selectEmptyDirectory")} ${t("common.ok")} `; document.body.appendChild(dialog); const close = () => document.body.removeChild(dialog); dialog .querySelector("#empty-dir-ok") ?.addEventListener("click", close); dialog.addEventListener("click", (e) => { if (e.target === dialog) close(); }); return; } await saveCustomPath( edition.instanceId, folder, ); toggleInstall(edition.instanceId); } } catch (err) { if (err !== "CANCELED") console.error(err); } }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.downloadToCustomPath")} )} {edition.officialDLC && isInstalled ? ( { e.stopPropagation(); playPressSound(); setDlcTargetEdition(edition); setIsDlcModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-[10px] text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.downloadDlc")} ) : null}{" "} {argsSchemas[edition.instanceId] && ( { e.stopPropagation(); playPressSound(); setOptionsTarget({ id: edition.instanceId, name: edition.name, }); setIsOptionsModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.options")} )} {Array.isArray(edition.branches) && edition.branches.length > 0 && ( { e.stopPropagation(); playPressSound(); cycleBranch(edition.id); }} className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow flex items-center justify-between group" > {t("versions.channel")} {edition.selectedBranch ?? t("versions.latest")} )} { e.stopPropagation(); playPressSound(); toggleInstall(edition.instanceId); setOpenMenuId(null); }} className="w-full text-left px-3 py-1.5 text-xs text-[#dddddd] hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1" > {t("versions.repair")} {!isAndroid && ( { e.stopPropagation(); playPressSound(); TauriService.openInstanceFolder( edition.instanceId, ); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.openFolder")} )}{" "} {!isAndroid && ( { e.stopPropagation(); playPressSound(); const PANORAMA_PROFILES = [ "legacy_evolved", "vanilla_tu19", "360revived", "vanilla_tu24", ]; const panoId = PANORAMA_PROFILES.includes( edition.id, ) ? edition.id : "vanilla_tu19"; const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`; addToSteam( edition.instanceId, edition.name, edition.titleImage ?? "", panoramaUrl, ); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.addToSteam")} )}{" "} {!isAndroid && ( { e.stopPropagation(); playPressSound(); handleImportWorld(edition.instanceId); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.importWorld")} )}{" "} {!isAndroid && ( { e.stopPropagation(); playPressSound(); TauriService.backupInstance( edition.instanceId, ).catch((err) => { if (err !== "CANCELED") console.error(err); }); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.backup")} )}{" "} {!isAndroid && ( { e.stopPropagation(); playPressSound(); setOpenMenuId(null); try { await TauriService.restoreInstance(); } catch (err) { if (err !== "CANCELED") console.error(err); } }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.restore")} )}{" "} {!isAndroid && ( { e.stopPropagation(); playPressSound(); setCustomizeTarget(edition); setIsCustomizeModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.customize")} )}{" "} { e.stopPropagation(); playPressSound(); setSetUidTargetId(edition.instanceId); setIsSetUidModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > {t("versions.setUid")} {" "} {isCustom ? ( { e.stopPropagation(); playPressSound(); setEditingEdition(edition); setIsImportModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] flex items-center gap-2 mc-text-shadow" > {t("versions.editCustom")} ) : null}{" "} { e.stopPropagation(); playBackSound(); if (isCustom) { onDeleteEdition(edition.id); } else { setDeleteConfirmEdition(edition); } setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors mc-text-shadow font-bold" > {isCustom ? t("versions.removeCustom") : t("versions.uninstall")} )} ); })} { playPressSound(); setInitialPath(""); setIsImportModalOpen(true); }} onMouseEnter={() => setFocusIndex(visibleEditions.length)} onMouseLeave={() => setHoveredBtn(null)} className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" style={{ backgroundImage: (hoveredBtn?.row === visibleEditions.length && hoveredBtn?.btn === "add") || focusIndex === visibleEditions.length ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > {!isAndroid && ( { playPressSound(); handleImportFolder(); }} onMouseEnter={() => setFocusIndex(visibleEditions.length + 1)} onMouseLeave={() => setHoveredBtn(null)} title={t("modals.customTu.importTitle")} className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" style={{ backgroundImage: (hoveredBtn?.row === visibleEditions.length && hoveredBtn?.btn === "folder_import") || focusIndex === visibleEditions.length + 1 ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > )} {!isAndroid && ( setFocusIndex(visibleEditions.length + 2)} onClick={() => { playBackSound(); setActiveView("main"); }} className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white" style={{ backgroundImage: focusIndex === visibleEditions.length + 2 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > {t("common.done")} {" "} )} { setIsImportModalOpen(false); setEditingEdition(null); setInitialPath(""); }} onImport={(ed: { name: string; desc: string; url: string; path?: string; }) => { if (editingEdition) { onUpdateEdition(editingEdition.id, ed); } else { const id = onAddEdition(ed); setSelectedProfile(id); } }} playPressSound={playPressSound} playBackSound={playBackSound} editingEdition={editingEdition} initialPath={initialPath} /> setIsSetUidModalOpen(false)} playPressSound={playPressSound} playBackSound={playBackSound} instances={visibleEditions} installedVersions={installedVersions} targetInstanceId={setUidTargetId} /> { setIsImportWorldModalOpen(false); setImportWorldTarget(null); }} playPressSound={playPressSound} playBackSound={playBackSound} targetInstanceId={importWorldTarget?.id ?? ""} targetInstanceName={importWorldTarget?.name ?? ""} /> { setIsPlaytimeModalOpen(false); setPlaytimeTarget(null); }} playBackSound={playBackSound} instanceId={playtimeTarget?.id ?? ""} instanceName={playtimeTarget?.name ?? ""} /> { setIsCustomizeModalOpen(false); setCustomizeTarget(null); }} playPressSound={playPressSound} playBackSound={playBackSound} editionName={customizeTarget?.name ?? ""} currentTitleImage={ customizeTarget ? customizations[customizeTarget.instanceId]?.titleImage || customizeTarget.titleImage : undefined } currentPanorama={ customizeTarget ? customizations[customizeTarget.instanceId]?.panorama || customizeTarget.panorama : undefined } onSave={(updates) => { if (customizeTarget) { updateCustomization(customizeTarget.instanceId, updates); } }} /> { setIsDlcModalOpen(false); setDlcTargetEdition(null); }} playPressSound={playPressSound} playBackSound={playBackSound} editionName={dlcTargetEdition?.name ?? ""} instanceId={dlcTargetEdition?.instanceId ?? ""} officialDLC={dlcTargetEdition?.officialDLC ?? ""} /> { setIsOptionsModalOpen(false); setOptionsTarget(null); }} playPressSound={playPressSound} playBackSound={playBackSound} instanceId={optionsTarget?.id ?? ""} instanceName={optionsTarget?.name ?? ""} savedValues={ optionsTarget ? instanceLaunchArgs[optionsTarget.id]?.values : undefined } onSave={(instanceId, values, args) => { setInstanceLaunchArgs((prev) => ({ ...prev, [instanceId]: { values, args }, })); }} /> {deleteConfirmEdition && ( {t("versions.deleteTitle", { name: deleteConfirmEdition.name })} {t("versions.deleteWarning")} { playBackSound(); setDeleteConfirmEdition(null); }} /> { playPressSound(); handleUninstall(deleteConfirmEdition.id); setDeleteConfirmEdition(null); }} /> )} ); }); export default VersionsView;
{edition.desc}
${t("versions.selectEmptyDirectory")}
{t("versions.deleteWarning")}