import { useState, useEffect, useLayoutEffect, useRef, useCallback, 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 ( ); }); const ROW_ESTIMATE = 52; const ROW_GAP = 4; const VersionsView = memo(function VersionsView() { const { t } = useTranslation(); const { setActiveView } = useUI(); const { setProfile: setSelectedProfile, animationsEnabled, instanceLaunchArgs, setInstanceLaunchArgs, } = useConfig(); const { playPressSound, playBackSound } = useAudio(); const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingIds, 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 [thumb, setThumb] = useState({ height: 0, top: 0 }); const containerRef = useRef(null); const listRef = useRef(null); const trackRef = useRef(null); const thumbRef = useRef(null); const dragOffsetRef = useRef(0); const draggingRef = useRef(false); const menuRef = useRef(null); const [menuDir, setMenuDir] = useState<"down" | "up">("down"); const ITEM_COUNT = visibleEditions.length; const [visibleRange, setVisibleRange] = useState<{ start: number; end: number; }>({ start: 0, end: visibleEditions.length ? visibleEditions.length - 1 : 0, }); const itemHeightsRef = useRef([]); const observersRef = useRef>(new Map()); const visibleEditionsRef = useRef(visibleEditions); visibleEditionsRef.current = visibleEditions; const focusIndexRef = useRef(focusIndex); const openMenuIdRef = useRef(openMenuId); const updateThumb = useCallback(() => { const container = listRef.current; const track = trackRef.current; if (!container || !track) return; const scrollHeight = container.scrollHeight; const clientHeight = container.clientHeight; const scrollable = scrollHeight - clientHeight; if (scrollable <= 0) { setThumb((prev) => prev.height === 0 && prev.top === 0 ? prev : { height: 0, top: 0 }, ); return; } const trackHeight = track.clientHeight; const HANDLE = 40; const STEP = HANDLE + 4; const maxTop = trackHeight - HANDLE; const maxIndex = Math.max(1, Math.floor(maxTop / STEP)); const index = Math.min( maxIndex, Math.round((container.scrollTop / scrollable) * maxIndex), ); const top = index * STEP; setThumb((prev) => prev.top === top && prev.height === HANDLE ? prev : { height: HANDLE, top }, ); }, []); const handleThumbPointerDown = (e: React.PointerEvent) => { e.preventDefault(); draggingRef.current = true; dragOffsetRef.current = e.clientY - e.currentTarget.getBoundingClientRect().top; e.currentTarget.setPointerCapture(e.pointerId); }; const handleThumbPointerMove = (e: React.PointerEvent) => { if (!draggingRef.current) return; const container = listRef.current; const track = trackRef.current; if (!container || !track) return; const trackRect = track.getBoundingClientRect(); const trackTop = trackRect.top + track.clientTop; const y = e.clientY - trackTop - dragOffsetRef.current; const trackHeight = track.clientHeight; const maxTop = Math.max(0, trackHeight - thumb.height); const scrollable = container.scrollHeight - container.clientHeight; if (scrollable <= 0) return; const clampedY = Math.min(Math.max(y, 0), maxTop); container.scrollTop = (clampedY / maxTop) * scrollable; }; const handleThumbPointerUp = () => { draggingRef.current = false; }; const updateVisibleRange = useCallback(() => { const container = listRef.current; const editions = visibleEditionsRef.current; if (!container) return; const scrollTop = container.scrollTop; const viewportHeight = container.clientHeight; const heights = itemHeightsRef.current; const n = editions.length; let acc = 0; let start = n; let end = -1; for (let i = 0; i < n; i++) { const itemTop = acc; const itemNeo = acc + (heights[i] ?? ROW_ESTIMATE); //neo: me fr fr :3 if (itemTop >= scrollTop && itemNeo <= scrollTop + viewportHeight) { if (start === n) start = i; end = i; } acc += (heights[i] ?? ROW_ESTIMATE) + ROW_GAP; } if (start === n) start = 0; if (end < 0) end = n - 1; const fi = focusIndexRef.current; if (fi >= 0 && fi < n) { start = Math.min(start, fi); end = Math.max(end, fi); } const menuId = openMenuIdRef.current; if (menuId) { const mi = editions.findIndex((e) => e.id === menuId); if (mi >= 0) { start = Math.min(start, mi); end = Math.max(end, mi); } } setVisibleRange((prev) => prev.start === start && prev.end === end ? prev : { start, end }, ); updateThumb(); }, [updateThumb]); const setRowRef = (index: number) => (el: HTMLDivElement | null) => { if (el) { let ro = observersRef.current.get(index); if (!ro) { ro = new ResizeObserver(() => { itemHeightsRef.current[index] = el.offsetHeight; updateVisibleRange(); }); observersRef.current.set(index, ro); } ro.observe(el); } else { const ro = observersRef.current.get(index); if (ro) { ro.disconnect(); observersRef.current.delete(index); } } }; useEffect(() => { focusIndexRef.current = focusIndex; }, [focusIndex]); useEffect(() => { openMenuIdRef.current = openMenuId; }, [openMenuId]); useEffect(() => { const container = listRef.current; if (!container) return; const ro = new ResizeObserver(() => updateVisibleRange()); ro.observe(container); updateVisibleRange(); return () => { ro.disconnect(); observersRef.current.forEach((obs) => obs.disconnect()); observersRef.current.clear(); }; }, [updateVisibleRange]); 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.instanceId); const maxBtn = isInstalled ? 1 : 2; 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.instanceId); const maxBtn = isInstalled ? 1 : 2; 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) { playPressSound(); setOpenMenuId(null); setSelectedProfile(edition.instanceId); } else if (focusBtn === 2 || (focusBtn === 1 && isInstalled)) { playPressSound(); setOpenMenuId(openMenuId === edition.id ? null : edition.id); } else if (focusBtn === 1 && !isInstalled) { playPressSound(); if (isDownloading) { handleCancelDownload(edition.instanceId); } else { toggleInstall(edition.instanceId); } } } 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]); useLayoutEffect(() => { if (!openMenuId) return; const container = listRef.current; const menu = menuRef.current; if (!container || !menu) return; const row = menu.closest("[data-index]") as HTMLElement | null; if (!row) return; const containerRect = container.getBoundingClientRect(); const rowRect = row.getBoundingClientRect(); const menuHeight = menu.offsetHeight; const spaceBelow = containerRect.bottom - rowRect.bottom; const spaceAbove = rowRect.top - containerRect.top; setMenuDir( spaceBelow < menuHeight && spaceAbove > spaceBelow ? "up" : "down", ); }, [openMenuId]); 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) => { 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 (
{visibleEditions.map((edition: Edition, i: number) => { if (i < visibleRange.start || i > visibleRange.end) { return (
); } const isInstalled = installedVersions.includes( edition.instanceId, ); const isFocused = focusIndex === i; const isCustom = edition.id.startsWith("custom_"); const isDownloading = downloadingIds.includes(edition.instanceId); const isComingSoon = edition.comingSoon; const isDownloadFocused = !isInstalled && isFocused && focusBtn === 1; const isGearFocused = isFocused && focusBtn === (isInstalled ? 1 : 2); return (
!isComingSoon && setFocusIndex(i)} onMouseLeave={() => setFocusIndex(-1)} //neo: you see this, smartcmd? onClick={() => !isComingSoon && handleEditionClick(edition, i) } >
{edition.logo ? ( edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( ) : ( ) ) : (
)}
{edition.name} {edition.category && edition.category.map((cat: string) => ( {cat} ))} {isCustom && !edition.category && ( {t("versions.custom")} )}
{!isInstalled && ( )} {openMenuId === edition.id && (
{updatesAvailable?.[edition.instanceId] && ( )} {!isAndroid && !isInstalled && (
`; 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 ? ( ) : null}{" "} {argsSchemas[edition.instanceId] && ( )} {Array.isArray(edition.branches) && edition.branches.length > 0 && ( )}
{!isAndroid && ( )}{" "} {!isAndroid && ( )}{" "} {!isAndroid && ( )}{" "} {!isAndroid && ( )}{" "} {!isAndroid && ( )}{" "} {!isAndroid && ( )}{" "} {" "} {isCustom ? ( ) : null}{" "}
)}
); })}
{thumb.height > 0 && (
{ //neo: oh my goodness this took me a lot to figure out, anyway its just a transparent
so the div isnt empty so that the border-image renders }
)}
{!isAndroid && (
)}
{visibleEditions[focusIndex]?.desc ?? visibleEditions.find((e) => e.id == useConfig().profile)?.desc ?? ""}
{!isAndroid && (
{" "}
)} { 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;