feat: game library sorting

This commit is contained in:
Vinicius Rangel
2025-05-12 16:18:34 -03:00
parent 2d066153c2
commit a318261323
5 changed files with 125 additions and 62 deletions

View File

@@ -2,9 +2,20 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { exists, mkdir } from "@tauri-apps/plugin-fs"; import { exists, mkdir } from "@tauri-apps/plugin-fs";
import { useAtomValue, useStore } from "jotai"; import { useAtomValue, useStore } from "jotai";
import { Fragment, Suspense, useEffect, useRef, useState } from "react"; import {
Fragment,
Suspense,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { openPath } from "@/lib/native/common"; import { openPath } from "@/lib/native/common";
import { atomGameLibrary } from "@/store/game-library"; import {
atomGameLibrary,
atomGameLibrarySorting,
SortType,
} from "@/store/game-library";
import { atomGamesPath } from "@/store/paths"; import { atomGamesPath } from "@/store/paths";
import { import {
EmptyGameBox, EmptyGameBox,
@@ -44,8 +55,24 @@ function Grid() {
const parentRef = useRef<HTMLDivElement | null>(null); const parentRef = useRef<HTMLDivElement | null>(null);
const { games } = useAtomValue(atomGameLibrary); const { games } = useAtomValue(atomGameLibrary);
const sortType = useAtomValue(atomGameLibrarySorting);
const [itemPerRow, setItemPerRow] = useState(1); const [itemPerRow, setItemPerRow] = useState(1);
const sortedGames = useMemo(() => {
switch (sortType) {
case SortType.NONE:
return games;
case SortType.TITLE:
return games.toSorted((a, b) => a.title.localeCompare(b.title));
case SortType.CUSA:
return games.toSorted((a, b) => a.cusa.localeCompare(b.cusa));
default: {
const _ret: never = sortType;
return _ret;
}
}
}, [games, sortType]);
const rowCount = Math.ceil(games.length / itemPerRow); const rowCount = Math.ceil(games.length / itemPerRow);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
@@ -99,7 +126,7 @@ function Grid() {
{items.map((row) => { {items.map((row) => {
const firstIdx = row.index * itemPerRow; const firstIdx = row.index * itemPerRow;
const lastIdx = firstIdx + itemPerRow; const lastIdx = firstIdx + itemPerRow;
const entries = games.slice(firstIdx, lastIdx); const entries = sortedGames.slice(firstIdx, lastIdx);
return ( return (
<div <div
className="flex gap-4 pb-4" className="flex gap-4 pb-4"
@@ -109,7 +136,7 @@ function Grid() {
> >
{entries.map((game) => ( {entries.map((game) => (
<Fragment key={game.path}> <Fragment key={game.path}>
{"error" in game ? ( {"error" in game && game.error ? (
<GameBoxError err={game.error} /> <GameBoxError err={game.error} />
) : ( ) : (
<GameBox game={game} /> <GameBox game={game} />

View File

@@ -1,3 +1,4 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { TooltipTrigger } from "@radix-ui/react-tooltip"; import { TooltipTrigger } from "@radix-ui/react-tooltip";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import {
@@ -11,7 +12,11 @@ import { openEmuConfigWindow } from "@/handlers/window";
import { GamepadNavField } from "@/lib/context/gamepad-nav-field"; import { GamepadNavField } from "@/lib/context/gamepad-nav-field";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { atomFolderConfigModalIsOpen, oficialRepo } from "@/store/common"; import { atomFolderConfigModalIsOpen, oficialRepo } from "@/store/common";
import { atomGameLibrary } from "@/store/game-library"; import {
atomGameLibrary,
atomGameLibrarySorting,
SortType,
} from "@/store/game-library";
import { import {
atomInstalledVersions, atomInstalledVersions,
atomModalVersionManagerIsOpen, atomModalVersionManagerIsOpen,
@@ -33,6 +38,7 @@ import {
} from "./ui/select"; } from "./ui/select";
import { Spinner } from "./ui/spinner"; import { Spinner } from "./ui/spinner";
import { Tooltip, TooltipContent } from "./ui/tooltip"; import { Tooltip, TooltipContent } from "./ui/tooltip";
import { FilterIcon } from "lucide-react";
function VersionSelector() { function VersionSelector() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -119,20 +125,42 @@ function ToolbarButton({
export function Toolbar() { export function Toolbar() {
const setFolderConfigModalOpen = useSetAtom(atomFolderConfigModalIsOpen); const setFolderConfigModalOpen = useSetAtom(atomFolderConfigModalIsOpen);
const [sort, setSort] = useAtom(atomGameLibrarySorting);
const { indexing } = useAtomValue(atomGameLibrary); const { indexing } = useAtomValue(atomGameLibrary);
return ( return (
<div className="sticky top-0 z-30 flex justify-between border-b-2 p-3 px-10"> <div className="sticky top-0 z-30 flex justify-between border-b-2 p-3 px-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative"> <div className="relative flex">
<SearchIcon className="absolute top-2.5 left-2 size-4 text-muted-foreground" /> <SearchIcon className="absolute top-2.5 left-2 size-4 text-muted-foreground" />
<Navigable anchor="CENTER_LEFT"> <Navigable anchor="CENTER_LEFT">
<Input <Input
className="w-full pl-8" className="w-full rounded-r-none pl-8"
placeholder="Search..." placeholder="Search..."
type="search" type="search"
/> />
</Navigable> </Navigable>
<Select
value={sort}
onValueChange={(e) => setSort(e as SortType)}
>
<SelectPrimitive.Trigger asChild>
<Button
size="icon"
variant="link"
className="rounded-l-none border-1 border-l-0"
>
<FilterIcon />
</Button>
</SelectPrimitive.Trigger>
<SelectContent>
<SelectItem value={SortType.NONE}>None</SelectItem>
<SelectItem value={SortType.TITLE}>
Title
</SelectItem>
<SelectItem value={SortType.CUSA}>CUSA</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ToolbarButton <ToolbarButton

View File

@@ -13,21 +13,25 @@ function getStore(path: string): Promise<Store> {
return s; return s;
} }
export function atomWithTauriStore<T>( /**
* This uses config store underlying. This is not safe to read directly from store,
* as the value prob will not be available in the first read
*/
export function atomWithTauriStore<T, Nullable extends boolean = true>(
path: string, path: string,
key: string, key: string,
{ {
initialValue, initialValue,
onMount = initialValue, onMount = initialValue,
mergeInitial = true, mergeInitial = true,
}: }: Nullable extends false
| { ? {
initialValue: T; initialValue: T;
onMount?: T | (() => Promise<T> | T); onMount?: T | (() => Promise<T> | T);
mergeInitial?: boolean; mergeInitial?: boolean;
} }
| { : {
initialValue?: T; initialValue?: T | undefined;
onMount: T | (() => Promise<T> | T); onMount: T | (() => Promise<T> | T);
mergeInitial?: boolean; mergeInitial?: boolean;
}, },
@@ -43,7 +47,7 @@ export function atomWithTauriStore<T>(
const initial: T = await Promise.resolve(initialProm); const initial: T = await Promise.resolve(initialProm);
try { try {
const store = await getStore(path); const store = await getStore(path);
const value = await store.get<T>(key); const value = (await store.get<T>(key)) ?? null;
if (mergeInitial) { if (mergeInitial) {
if (Array.isArray(initial)) { if (Array.isArray(initial)) {
return [...initial, ...((value as T[]) || [])] as T; return [...initial, ...((value as T[]) || [])] as T;
@@ -61,20 +65,24 @@ export function atomWithTauriStore<T>(
} }
}; };
const baseAtom = atom<T | null>(initialValue ?? null); const baseAtom = atom(
(initialValue ?? null) as Nullable extends true ? T | null : T,
);
baseAtom.onMount = (setAtom) => { baseAtom.onMount = (setAtom) => {
void Promise.resolve(getInitialValue()).then(setAtom); void Promise.resolve(getInitialValue()).then((e) =>
setAtom(e as Nullable extends true ? T | null : T),
);
}; };
return atom<T | null, [T], void>( return atom<Nullable extends true ? T | null : T, [T], void>(
(get) => get(baseAtom), (get) => get(baseAtom),
(get, set, update: SetStateAction<T | null>) => { (get, set, update: SetStateAction<T | null>) => {
const newValue = const newValue =
typeof update === "function" typeof update === "function"
? (update as (prev: T | null) => T)(get(baseAtom)) ? (update as (prev: T | null) => T)(get(baseAtom) ?? null)
: update; : update;
set(baseAtom, newValue); set(baseAtom, newValue as Nullable extends true ? T | null : T);
void getStore(path).then((store) => void store.set(key, newValue)); void getStore(path).then((store) => void store.set(key, newValue));
}, },
); );

View File

@@ -4,13 +4,26 @@ import { atom } from "jotai";
import { toast } from "sonner"; import { toast } from "sonner";
import { readPsf } from "@/lib/native/psf"; import { readPsf } from "@/lib/native/psf";
import { stringifyError } from "@/lib/utils/error"; import { stringifyError } from "@/lib/utils/error";
import { atomWithTauriStore } from "@/lib/utils/jotai/tauri-store";
import { defaultStore } from "."; import { defaultStore } from ".";
import { db, type GameRow } from "./db"; import { db, type GameRow } from "./db";
import { atomGamesPath } from "./paths"; import { atomGamesPath } from "./paths";
export enum SortType {
NONE = "None",
TITLE = "Title",
CUSA = "CUSA",
}
export const atomGameLibrarySorting = atomWithTauriStore<SortType, false>(
"config.json",
"game_library_sort",
{ initialValue: SortType.NONE },
);
export const atomGameLibrary = atom<{ export const atomGameLibrary = atom<{
indexing: boolean; indexing: boolean;
games: (GameRow | { path: string; error: Error })[]; games: (GameRow & { error?: Error })[];
}>({ }>({
indexing: false, indexing: false,
games: [], games: [],
@@ -18,7 +31,7 @@ export const atomGameLibrary = atom<{
async function loadGameData( async function loadGameData(
path: string, path: string,
): Promise<GameRow | { path: string; error: Error }> { ): Promise<GameRow & { error?: Error }> {
try { try {
const base = await basename(path); const base = await basename(path);
@@ -38,13 +51,6 @@ async function loadGameData(
const sfo = await readPsf(paramSfo); const sfo = await readPsf(paramSfo);
const e = sfo.entries; const e = sfo.entries;
/* let cover: string | null = await join(path, "sce_sys", "icon0.png");
if (!(await exists(cover))) {
cover = null;
} else {
cover = convertFileSrc(cover);
} */
let fw_version = e.SYSTEM_VER?.Integer?.toString(16) let fw_version = e.SYSTEM_VER?.Integer?.toString(16)
.padStart(8, "0") .padStart(8, "0")
.slice(0, 4); .slice(0, 4);
@@ -67,6 +73,11 @@ async function loadGameData(
console.error(`could not read game info at: "${path}"`, e); console.error(`could not read game info at: "${path}"`, e);
return { return {
path: path, path: path,
cusa: "N/A",
title: "N/A",
version: "N/A",
fw_version: "N/A",
sfo: null,
error: new Error(`game read info. ${stringifyError(e)}`, { error: new Error(`game read info. ${stringifyError(e)}`, {
cause: e, cause: e,
}), }),
@@ -118,13 +129,15 @@ async function scanDirectory(
signal: AbortSignal, signal: AbortSignal,
recursionLevel: number, recursionLevel: number,
) { ) {
if (recursionLevel > 3 || signal.aborted) {
return;
}
if (knownPaths.has(path)) {
return;
}
try { try {
indexingCount++;
if (recursionLevel > 3 || signal.aborted) {
return;
}
if (knownPaths.has(path)) {
return;
}
if (path.endsWith("-UPDATE") || path.endsWith("-patch")) { if (path.endsWith("-UPDATE") || path.endsWith("-patch")) {
return; return;
} }
@@ -136,26 +149,24 @@ async function scanDirectory(
for (const c of children) { for (const c of children) {
if (c.isDirectory) { if (c.isDirectory) {
const childPath = await join(path, c.name); const childPath = await join(path, c.name);
indexingCount++; scanDirectory(
setTimeout(() => { childPath,
scanDirectory( knownPaths,
childPath, signal,
knownPaths, recursionLevel + 1,
signal, );
recursionLevel + 1,
);
indexingCount--;
if (indexingCount === 0) {
defaultStore.set(atomGameLibrary, (prev) => ({
...prev,
indexing: false,
}));
}
}, 1);
} }
} }
} catch (e: unknown) { } catch (e: unknown) {
console.error(`Error discovering game at "${path}"`, e); console.error(`Error discovering game at "${path}"`, e);
} finally {
indexingCount--;
if (indexingCount === 0) {
defaultStore.set(atomGameLibrary, (prev) => ({
...prev,
indexing: false,
}));
}
} }
} }
@@ -211,23 +222,12 @@ async function scanDirectory(
return; return;
} }
} }
indexingCount++;
scanDirectory( scanDirectory(
newPath, newPath,
knownPaths, knownPaths,
abortController.signal, abortController.signal,
1, 1,
); );
indexingCount--;
if (indexingCount === 0) {
defaultStore.set(
atomGameLibrary,
(prev) => ({
...prev,
indexing: false,
}),
);
}
} }
} else if ("remove" in e.type) { } else if ("remove" in e.type) {
const newPath = e.paths[0]; const newPath = e.paths[0];

View File

@@ -8,8 +8,8 @@
}, },
/* LANGUAGE COMPILATION OPTIONS */ /* LANGUAGE COMPILATION OPTIONS */
"target": "ES2022", "target": "ES2023",
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2023"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",