diff --git a/.vscode/settings.json b/.vscode/settings.json index 41531a4..28b818e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "src-tauri/**": true, "dist/**": true }, + "typescript.preferences.autoImportSpecifierExcludeRegexes": ["@radix-ui"], "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, diff --git a/src/app.tsx b/src/app.tsx index cc8f042..3bd9788 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,6 +2,7 @@ import { Suspense } from "react"; import { LoadingScreen } from "./components/loading-overlay"; import { MainPage } from "./components/main-page"; import { FolderConfigModal } from "./components/modals/folder-config-modal"; +import { GameDetailsModal } from "./components/modals/game-details-modal"; import { RunningGameModal } from "./components/modals/running-game-modal"; import { VersionManagerModal } from "./components/modals/version-manager-modal"; import { UpdateIcon } from "./components/update-icon"; @@ -19,6 +20,7 @@ export function App() { + diff --git a/src/components/game-box.tsx b/src/components/game-box.tsx new file mode 100644 index 0000000..e66ee8c --- /dev/null +++ b/src/components/game-box.tsx @@ -0,0 +1,179 @@ +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; +import { CircleHelp, FrownIcon, Globe, ImageOff, Play } from "lucide-react"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import CN from "@/assets/flags/cn.svg"; +import EU from "@/assets/flags/eu.svg"; +import JP from "@/assets/flags/jp.svg"; +import US from "@/assets/flags/us.svg"; +import { startGame } from "@/handlers/run-emu"; +import type { PSF } from "@/lib/native/psf"; +import { atomShowingGameDetails } from "@/store/common"; +import type { GameEntry } from "@/store/game-library"; +import { gamepadActiveAtom } from "@/store/gamepad"; +import { atomShowingRunningGame } from "@/store/running-games"; +import { atomSelectedVersion } from "@/store/version-manager"; +import { stringifyError } from "@/utils/error"; +import GamepadIcon from "./gamepad-icon"; +import { Skeleton } from "./ui/skeleton"; +import { Spinner } from "./ui/spinner"; + +function Flag({ sfo, className }: { sfo: PSF | null; className?: string }) { + const region = useMemo(() => { + const { CONTENT_ID } = sfo?.entries ?? {}; + return CONTENT_ID?.Text?.[0] ?? undefined; + }, [sfo]); + + switch (region) { + case "U": + return US; + case "E": + return EU; + case "J": + return JP; + case "H": + return CN; + case "I": + return ; + default: + return ; + } +} + +export function GameBoxSkeleton() { + return ( + + ); +} + +export function EmptyGameBox() { + return ( +
+ ); +} + +export function GameBox({ + game, + isFirst, +}: { + game: GameEntry; + isFirst?: boolean; +}) { + const [isPending, startTransaction] = useTransition(); + + const valueData = useAtomValue(game.dataLoadable); + const isGamepad = useAtom(gamepadActiveAtom); + const store = useStore(); + const setShowingDetails = useSetAtom(atomShowingGameDetails); + + const [clickCount, setClickCount] = useState(0); + + useEffect(() => { + if (clickCount >= 3) { + setClickCount(0); + toast.info("Do a double click to start the game"); + } + }, [clickCount]); + + const openGame = () => + startTransaction(async () => { + try { + setClickCount(0); + const selectEmu = store.get(atomSelectedVersion); + if (!selectEmu) { + toast.warning("No emulator selected"); + return; + } + const r = await startGame(selectEmu, game); + store.set(atomShowingRunningGame, r); + } catch (e: unknown) { + toast.error("Unknown error: " + stringifyError(e)); + } + }); + + const onClick = () => { + setClickCount((prev) => prev + 1); + }; + + const onBlur = () => { + setClickCount(0); + }; + + if (valueData.state === "loading") { + return ; + } + + if (valueData.state === "hasError") { + return ( +
+
+ + + Error: {stringifyError(valueData.error)} + +
+
+ ); + } + + const data = valueData.data; + + return ( +
+ {isPending && ( +
+ +
+ )} + {data.cover ? ( + {data.title} + ) : ( +
+ +
+ )} + +
+ + {/* TODO: scroll text on overflow */} + {data.title} + + +
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/game-library.tsx b/src/components/game-library.tsx index 124dad5..836b524 100644 --- a/src/components/game-library.tsx +++ b/src/components/game-library.tsx @@ -1,186 +1,14 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { useVirtualizer } from "@tanstack/react-virtual"; import { exists, mkdir } from "@tauri-apps/plugin-fs"; -import { useAtom, useAtomValue, useStore } from "jotai"; -import { CircleHelp, FrownIcon, Globe, ImageOff, Play } from "lucide-react"; -import { - Suspense, - useEffect, - useMemo, - useRef, - useState, - useTransition, -} from "react"; -import { toast } from "sonner"; -import CN from "@/assets/flags/cn.svg"; -import EU from "@/assets/flags/eu.svg"; -import JP from "@/assets/flags/jp.svg"; -import US from "@/assets/flags/us.svg"; -import { startGame } from "@/handlers/run-emu"; +import { useAtom, useStore } from "jotai"; +import { Suspense, useEffect, useRef, useState } from "react"; + import { openPath } from "@/lib/native/common"; -import type { PSF } from "@/lib/native/psf"; -import { atomGameLibrary, type GameEntry } from "@/store/game-library"; -import { gamepadActiveAtom } from "@/store/gamepad"; +import { atomGameLibrary } from "@/store/game-library"; import { atomGamesPath } from "@/store/paths"; -import { atomShowingRunningGame } from "@/store/running-games"; -import { atomSelectedVersion } from "@/store/version-manager"; -import { stringifyError } from "@/utils/error"; -import GamepadIcon from "./gamepad-icon"; +import { EmptyGameBox, GameBox, GameBoxSkeleton } from "./game-box"; import { ScrollBar } from "./ui/scroll-area"; -import { Skeleton } from "./ui/skeleton"; -import { Spinner } from "./ui/spinner"; - -function Flag({ sfo, className }: { sfo: PSF | null; className?: string }) { - const region = useMemo(() => { - const { CONTENT_ID } = sfo?.entries ?? {}; - return CONTENT_ID?.Text?.[0] ?? undefined; - }, [sfo]); - - switch (region) { - case "U": - return US; - case "E": - return EU; - case "J": - return JP; - case "H": - return CN; - case "I": - return ; - default: - return ; - } -} - -function EmptyGameBox() { - return ( -
- ); -} - -function GameBoxSkeleton() { - return ( - - ); -} - -function GameBox({ game, isFirst }: { game: GameEntry; isFirst?: boolean }) { - const [isPending, startTransaction] = useTransition(); - - const valueData = useAtomValue(game.dataLoadable); - const isGamepad = useAtom(gamepadActiveAtom); - const store = useStore(); - - const [clickCount, setClickCount] = useState(0); - - useEffect(() => { - if (clickCount >= 3) { - setClickCount(0); - toast.info("Do a double click to start the game"); - } - }, [clickCount]); - - const openGame = () => - startTransaction(async () => { - try { - setClickCount(0); - const selectEmu = store.get(atomSelectedVersion); - if (!selectEmu) { - toast.warning("No emulator selected"); - return; - } - const r = await startGame(selectEmu, game); - store.set(atomShowingRunningGame, r); - } catch (e: unknown) { - toast.error("Unknown error: " + stringifyError(e)); - } - }); - - const onClick = () => { - setClickCount((prev) => prev + 1); - }; - - const onBlur = () => { - setClickCount(0); - }; - - if (valueData.state === "loading") { - return ; - } - - if (valueData.state === "hasError") { - return ( -
-
- - - Error: {stringifyError(valueData.error)} - -
-
- ); - } - - const data = valueData.data; - - return ( -
- {isPending && ( -
- -
- )} - {data.cover ? ( - {data.title} - ) : ( -
- -
- )} - -
- - {/* TODO: scroll text on overflow */} - {data.title} - - -
- -
- - - - -
-
- ); -} function NoGameFound() { const store = useStore(); diff --git a/src/components/modals/game-details-modal.tsx b/src/components/modals/game-details-modal.tsx new file mode 100644 index 0000000..06e08ab --- /dev/null +++ b/src/components/modals/game-details-modal.tsx @@ -0,0 +1,198 @@ +import { useAtom } from "jotai"; +import { ImageOffIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { PSFEntry } from "@/lib/native/psf"; +import { atomShowingGameDetails } from "@/store/common"; +import type { GameEntryData } from "@/store/game-library"; + +function Entry({ value }: { value: PSFEntry }) { + if (value === null || value === undefined) { + return N/A; + } + if (value.Text !== undefined) { + return <>{value.Text}; + } + if (value.Integer !== undefined) { + return <>{value.Integer.toString()}; + } + if (value.Binary !== undefined) { + return ( + + [Binary Data ({value.Binary.length} bytes)] + + ); + } + return [Unknown Format]; +} + +type Props = { + gameData: GameEntryData; + onClose: () => void; +}; + +function GameDetailsDialog({ gameData, onClose }: Props) { + return ( + onClose()} open> + + + + {gameData.title} + + + {gameData.cusa} + + + +
+
+
+ {gameData.cover ? ( + {`${gameData.title} + ) : ( +
+ +
+ )} +
+ +
+
+

+ Version +

+

+ {gameData.version} +

+
+
+

+ Firmware +

+ + {gameData.fw_version} + +
+ + {gameData.sfo?.entries && ( + <> + {gameData.sfo.entries.CATEGORY && ( +
+

+ Category +

+

+ +

+
+ )} + {gameData.sfo.entries.CONTENT_ID && ( +
+

+ Content ID +

+

+ +

+
+ )} + + )} +
+
+ + {gameData.sfo?.entries && ( +
+

+ SFO Details +

+ + + + + Key + + Value + + + + {Object.entries(gameData.sfo.entries) + .filter( + ([key]) => + ![ + "TITLE", + "TITLE_ID", + "APP_VER", + "SYSTEM_VER", + "CATEGORY", + "CONTENT_ID", + ].includes(key), + ) + .sort(([keyA], [keyB]) => + keyA.localeCompare(keyB), + ) + .map(([key, value]) => ( + + + {key} + + + + + + ))} + +
+
+ )} +
+
+
+ ); +} + +export function GameDetailsModal() { + const [showingGame, setShowingGame] = useAtom(atomShowingGameDetails); + + if (!showingGame) { + return <>; + } + + return ( + setShowingGame(null)} + /> + ); +} diff --git a/src/components/modals/running-game-modal.tsx b/src/components/modals/running-game-modal.tsx index 87756d9..b714186 100644 --- a/src/components/modals/running-game-modal.tsx +++ b/src/components/modals/running-game-modal.tsx @@ -30,7 +30,11 @@ import { } from "../ui/dialog"; import { Skeleton } from "../ui/skeleton"; -function RunningGameDialog({ runningGame }: { runningGame: RunningGame }) { +export function RunningGameDialog({ + runningGame, +}: { + runningGame: RunningGame; +}) { const store = useStore(); const setShowingGame = useSetAtom(atomShowingRunningGame); diff --git a/src/components/modals/version-manager-modal.tsx b/src/components/modals/version-manager-modal.tsx index ca8a1e9..f346444 100644 --- a/src/components/modals/version-manager-modal.tsx +++ b/src/components/modals/version-manager-modal.tsx @@ -198,7 +198,10 @@ export function VersionManagerModal() { {isNew ? ( ) : ( - +
diff --git a/src/store/common.ts b/src/store/common.ts index ea58178..040471d 100644 --- a/src/store/common.ts +++ b/src/store/common.ts @@ -1,5 +1,6 @@ import type { Update } from "@tauri-apps/plugin-updater"; import { atom } from "jotai"; +import type { GameEntryData } from "./game-library"; export const oficialRepo = "shadps4-emu/shadPS4"; @@ -17,3 +18,5 @@ export const atomDownloadingOverlay = atom< >(null); export const atomUpdateAvailable = atom(null); + +export const atomShowingGameDetails = atom(null);