feat: game details page

This commit is contained in:
Vinicius Rangel
2025-04-25 00:17:47 -03:00
parent bb15662b12
commit 5779babb9f
8 changed files with 397 additions and 179 deletions

View File

@@ -5,6 +5,7 @@
"src-tauri/**": true,
"dist/**": true
},
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["@radix-ui"],
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},

View File

@@ -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() {
<VersionManagerModal />
<UpdateIcon />
<RunningGameModal />
<GameDetailsModal />
<MainPage />
</Suspense>
</main>

179
src/components/game-box.tsx Normal file
View File

@@ -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 <img alt="US" className={className} src={US} />;
case "E":
return <img alt="EU" className={className} src={EU} />;
case "J":
return <img alt="JP" className={className} src={JP} />;
case "H":
return <img alt="CN" className={className} src={CN} />;
case "I":
return <Globe className={className} />;
default:
return <CircleHelp className={className} />;
}
}
export function GameBoxSkeleton() {
return (
<Skeleton className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 rounded-sm bg-zinc-800" />
);
}
export function EmptyGameBox() {
return (
<div className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 opacity-0" />
);
}
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<number>(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 <GameBoxSkeleton />;
}
if (valueData.state === "hasError") {
return (
<div className="relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110">
<div className="flex flex-col items-center justify-center gap-2">
<FrownIcon className="h-8" />
<span className="text-sm">
Error: {stringifyError(valueData.error)}
</span>
</div>
</div>
);
}
const data = valueData.data;
return (
<div
className="group relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110"
data-gamepad-selectable
onBlur={onBlur}
onClick={onClick}
onDoubleClick={openGame}
>
{isPending && (
<div className="center absolute inset-0 bg-black/60">
<Spinner />
</div>
)}
{data.cover ? (
<img
alt={data.title}
className="col-span-full row-span-full object-cover"
src={data.cover}
/>
) : (
<div className="center col-span-full row-span-full">
<ImageOff className="h-8" />
</div>
)}
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 bg-black/50 opacity-0 backdrop-blur-[2px] transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<span className="col-span-full row-start-1 row-end-2 truncate px-3 py-2 text-center font-semibold text-lg">
{/* TODO: scroll text on overflow */}
{data.title}
</span>
<div className="col-start-3 col-end-4 row-start-3 row-end-4 m-2 size-6 place-self-end">
<Flag className="rounded-full" sfo={data.sfo} />
</div>
<button
className="col-span-full row-span-full grid size-16 place-items-center place-self-center rounded-full bg-black/75"
data-initial-focus={isFirst ? "" : undefined}
data-play-game={""}
type="button"
>
<Play className="size-10" fill="currentColor" />
</button>
<button
className="col-span-full row-start-3 row-end-4 flex flex-row items-center justify-center gap-x-2 self-end py-2 transition-colors hover:bg-secondary/75 focus:bg-secondary/75"
onClick={() => setShowingDetails(data)}
type="button"
>
{isGamepad && (
<GamepadIcon className="size-6" icon="options" />
)}
View More
{isGamepad && <div className="size-6" />}
</button>
</div>
</div>
);
}

View File

@@ -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 <img alt="US" className={className} src={US} />;
case "E":
return <img alt="EU" className={className} src={EU} />;
case "J":
return <img alt="JP" className={className} src={JP} />;
case "H":
return <img alt="CN" className={className} src={CN} />;
case "I":
return <Globe className={className} />;
default:
return <CircleHelp className={className} />;
}
}
function EmptyGameBox() {
return (
<div className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 opacity-0" />
);
}
function GameBoxSkeleton() {
return (
<Skeleton className="aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 rounded-sm bg-zinc-800" />
);
}
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<number>(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 <GameBoxSkeleton />;
}
if (valueData.state === "hasError") {
return (
<div className="relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110">
<div className="flex flex-col items-center justify-center gap-2">
<FrownIcon className="h-8" />
<span className="text-sm">
Error: {stringifyError(valueData.error)}
</span>
</div>
</div>
);
}
const data = valueData.data;
return (
<div
className="group relative aspect-square h-auto w-full min-w-[150px] max-w-[200px] flex-1 cursor-pointer overflow-hidden rounded-sm bg-zinc-800 transition-transform focus-within:scale-110 hover:scale-110"
data-gamepad-selectable
onBlur={onBlur}
onClick={onClick}
onDoubleClick={openGame}
>
{isPending && (
<div className="center absolute inset-0 bg-black/60">
<Spinner />
</div>
)}
{data.cover ? (
<img
alt={data.title}
className="col-span-full row-span-full object-cover"
src={data.cover}
/>
) : (
<div className="center col-span-full row-span-full">
<ImageOff className="h-8" />
</div>
)}
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 bg-black/50 opacity-0 backdrop-blur-[2px] transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<span className="col-span-full row-start-1 row-end-2 truncate px-3 py-2 text-center font-semibold text-lg">
{/* TODO: scroll text on overflow */}
{data.title}
</span>
<div className="col-start-3 col-end-4 row-start-3 row-end-4 m-2 size-6 place-self-end">
<Flag className="rounded-full" sfo={data.sfo} />
</div>
<button
className="col-span-full row-span-full grid size-16 place-items-center place-self-center rounded-full bg-black/75"
data-initial-focus={isFirst ? "" : undefined}
data-play-game={""}
type="button"
>
<Play className="size-10" fill="currentColor" />
</button>
<button
className="col-span-full row-start-3 row-end-4 flex flex-row items-center justify-center gap-x-2 self-end py-2 transition-colors hover:bg-secondary/75 focus:bg-secondary/75"
type="button"
>
{isGamepad && (
<GamepadIcon className="size-6" icon="options" />
)}
View More
{isGamepad && <div className="size-6" />}
</button>
</div>
</div>
);
}
function NoGameFound() {
const store = useStore();

View File

@@ -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 <span className="text-muted-foreground italic">N/A</span>;
}
if (value.Text !== undefined) {
return <>{value.Text}</>;
}
if (value.Integer !== undefined) {
return <>{value.Integer.toString()}</>;
}
if (value.Binary !== undefined) {
return (
<span className="text-muted-foreground italic">
[Binary Data ({value.Binary.length} bytes)]
</span>
);
}
return <span className="text-red-500 italic">[Unknown Format]</span>;
}
type Props = {
gameData: GameEntryData;
onClose: () => void;
};
function GameDetailsDialog({ gameData, onClose }: Props) {
return (
<Dialog onOpenChange={() => onClose()} open>
<DialogContent
aria-describedby="game-details-description"
className="flex max-h-[90vh] max-w-xl flex-col md:max-w-4xl"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="text-2xl">
{gameData.title}
</DialogTitle>
<DialogDescription id="game-details-description">
{gameData.cusa}
</DialogDescription>
</DialogHeader>
<div className="flex-grow overflow-y-auto">
<div className="grid grid-cols-1 gap-6 py-4 sm:grid-cols-3">
<div className="flex flex-col items-center pt-2 md:col-span-1">
{gameData.cover ? (
<img
alt={`${gameData.title} Cover`}
className="aspect-[1/1] w-full max-w-[200px] rounded-lg object-contain shadow-md"
src={gameData.cover}
/>
) : (
<div className="flex aspect-[1/1] w-full max-w-[200px] items-center justify-center rounded-lg bg-muted text-muted-foreground shadow-md">
<ImageOffIcon className="h-12 w-12" />
</div>
)}
</div>
<div className="space-y-4 md:col-span-2">
<div className="space-y-1">
<h3 className="font-medium text-muted-foreground text-sm">
Version
</h3>
<p className="font-semibold text-lg">
{gameData.version}
</p>
</div>
<div className="space-y-1">
<h3 className="font-medium text-muted-foreground text-sm">
Firmware
</h3>
<Badge variant="outline">
{gameData.fw_version}
</Badge>
</div>
{gameData.sfo?.entries && (
<>
{gameData.sfo.entries.CATEGORY && (
<div className="space-y-1">
<h3 className="font-medium text-muted-foreground text-sm">
Category
</h3>
<p>
<Entry
value={
gameData.sfo.entries
.CATEGORY
}
/>
</p>
</div>
)}
{gameData.sfo.entries.CONTENT_ID && (
<div className="space-y-1">
<h3 className="font-medium text-muted-foreground text-sm">
Content ID
</h3>
<p className="break-all">
<Entry
value={
gameData.sfo.entries
.CONTENT_ID
}
/>
</p>
</div>
)}
</>
)}
</div>
</div>
{gameData.sfo?.entries && (
<div className="mt-4 border-t pt-4">
<h3 className="mb-2 font-semibold text-lg">
SFO Details
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[280px]">
Key
</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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]) => (
<TableRow key={key}>
<TableCell className="break-all py-1.5 font-medium">
{key}
</TableCell>
<TableCell className="break-all py-1.5">
<Entry value={value} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
export function GameDetailsModal() {
const [showingGame, setShowingGame] = useAtom(atomShowingGameDetails);
if (!showingGame) {
return <></>;
}
return (
<GameDetailsDialog
gameData={showingGame}
onClose={() => setShowingGame(null)}
/>
);
}

View File

@@ -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);

View File

@@ -198,7 +198,10 @@ export function VersionManagerModal() {
{isNew ? (
<AddNewVersion />
) : (
<DialogContent className="min-w-[525px]">
<DialogContent
aria-describedby={undefined}
className="min-w-[525px]"
>
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-4">

View File

@@ -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<Update | null>(null);
export const atomShowingGameDetails = atom<GameEntryData | null>(null);