mirror of
https://github.com/shadps4-emu/shadPS4-launcher.git
synced 2026-01-31 00:55:20 +01:00
feat: game library sorting
This commit is contained in:
@@ -2,9 +2,20 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { exists, mkdir } from "@tauri-apps/plugin-fs";
|
||||
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 { atomGameLibrary } from "@/store/game-library";
|
||||
import {
|
||||
atomGameLibrary,
|
||||
atomGameLibrarySorting,
|
||||
SortType,
|
||||
} from "@/store/game-library";
|
||||
import { atomGamesPath } from "@/store/paths";
|
||||
import {
|
||||
EmptyGameBox,
|
||||
@@ -44,8 +55,24 @@ function Grid() {
|
||||
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
const { games } = useAtomValue(atomGameLibrary);
|
||||
const sortType = useAtomValue(atomGameLibrarySorting);
|
||||
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 virtualizer = useVirtualizer({
|
||||
@@ -99,7 +126,7 @@ function Grid() {
|
||||
{items.map((row) => {
|
||||
const firstIdx = row.index * itemPerRow;
|
||||
const lastIdx = firstIdx + itemPerRow;
|
||||
const entries = games.slice(firstIdx, lastIdx);
|
||||
const entries = sortedGames.slice(firstIdx, lastIdx);
|
||||
return (
|
||||
<div
|
||||
className="flex gap-4 pb-4"
|
||||
@@ -109,7 +136,7 @@ function Grid() {
|
||||
>
|
||||
{entries.map((game) => (
|
||||
<Fragment key={game.path}>
|
||||
{"error" in game ? (
|
||||
{"error" in game && game.error ? (
|
||||
<GameBoxError err={game.error} />
|
||||
) : (
|
||||
<GameBox game={game} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
@@ -11,7 +12,11 @@ import { openEmuConfigWindow } from "@/handlers/window";
|
||||
import { GamepadNavField } from "@/lib/context/gamepad-nav-field";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { atomFolderConfigModalIsOpen, oficialRepo } from "@/store/common";
|
||||
import { atomGameLibrary } from "@/store/game-library";
|
||||
import {
|
||||
atomGameLibrary,
|
||||
atomGameLibrarySorting,
|
||||
SortType,
|
||||
} from "@/store/game-library";
|
||||
import {
|
||||
atomInstalledVersions,
|
||||
atomModalVersionManagerIsOpen,
|
||||
@@ -33,6 +38,7 @@ import {
|
||||
} from "./ui/select";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
import { Tooltip, TooltipContent } from "./ui/tooltip";
|
||||
import { FilterIcon } from "lucide-react";
|
||||
|
||||
function VersionSelector() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -119,20 +125,42 @@ function ToolbarButton({
|
||||
|
||||
export function Toolbar() {
|
||||
const setFolderConfigModalOpen = useSetAtom(atomFolderConfigModalIsOpen);
|
||||
const [sort, setSort] = useAtom(atomGameLibrarySorting);
|
||||
const { indexing } = useAtomValue(atomGameLibrary);
|
||||
|
||||
return (
|
||||
<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="relative">
|
||||
<div className="relative flex">
|
||||
<SearchIcon className="absolute top-2.5 left-2 size-4 text-muted-foreground" />
|
||||
<Navigable anchor="CENTER_LEFT">
|
||||
<Input
|
||||
className="w-full pl-8"
|
||||
className="w-full rounded-r-none pl-8"
|
||||
placeholder="Search..."
|
||||
type="search"
|
||||
/>
|
||||
</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 className="flex items-center gap-2">
|
||||
<ToolbarButton
|
||||
|
||||
@@ -13,21 +13,25 @@ function getStore(path: string): Promise<Store> {
|
||||
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,
|
||||
key: string,
|
||||
{
|
||||
initialValue,
|
||||
onMount = initialValue,
|
||||
mergeInitial = true,
|
||||
}:
|
||||
| {
|
||||
}: Nullable extends false
|
||||
? {
|
||||
initialValue: T;
|
||||
onMount?: T | (() => Promise<T> | T);
|
||||
mergeInitial?: boolean;
|
||||
}
|
||||
| {
|
||||
initialValue?: T;
|
||||
: {
|
||||
initialValue?: T | undefined;
|
||||
onMount: T | (() => Promise<T> | T);
|
||||
mergeInitial?: boolean;
|
||||
},
|
||||
@@ -43,7 +47,7 @@ export function atomWithTauriStore<T>(
|
||||
const initial: T = await Promise.resolve(initialProm);
|
||||
try {
|
||||
const store = await getStore(path);
|
||||
const value = await store.get<T>(key);
|
||||
const value = (await store.get<T>(key)) ?? null;
|
||||
if (mergeInitial) {
|
||||
if (Array.isArray(initial)) {
|
||||
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) => {
|
||||
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, set, update: SetStateAction<T | null>) => {
|
||||
const newValue =
|
||||
typeof update === "function"
|
||||
? (update as (prev: T | null) => T)(get(baseAtom))
|
||||
? (update as (prev: T | null) => T)(get(baseAtom) ?? null)
|
||||
: update;
|
||||
|
||||
set(baseAtom, newValue);
|
||||
set(baseAtom, newValue as Nullable extends true ? T | null : T);
|
||||
void getStore(path).then((store) => void store.set(key, newValue));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,13 +4,26 @@ import { atom } from "jotai";
|
||||
import { toast } from "sonner";
|
||||
import { readPsf } from "@/lib/native/psf";
|
||||
import { stringifyError } from "@/lib/utils/error";
|
||||
import { atomWithTauriStore } from "@/lib/utils/jotai/tauri-store";
|
||||
import { defaultStore } from ".";
|
||||
import { db, type GameRow } from "./db";
|
||||
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<{
|
||||
indexing: boolean;
|
||||
games: (GameRow | { path: string; error: Error })[];
|
||||
games: (GameRow & { error?: Error })[];
|
||||
}>({
|
||||
indexing: false,
|
||||
games: [],
|
||||
@@ -18,7 +31,7 @@ export const atomGameLibrary = atom<{
|
||||
|
||||
async function loadGameData(
|
||||
path: string,
|
||||
): Promise<GameRow | { path: string; error: Error }> {
|
||||
): Promise<GameRow & { error?: Error }> {
|
||||
try {
|
||||
const base = await basename(path);
|
||||
|
||||
@@ -38,13 +51,6 @@ async function loadGameData(
|
||||
const sfo = await readPsf(paramSfo);
|
||||
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)
|
||||
.padStart(8, "0")
|
||||
.slice(0, 4);
|
||||
@@ -67,6 +73,11 @@ async function loadGameData(
|
||||
console.error(`could not read game info at: "${path}"`, e);
|
||||
return {
|
||||
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)}`, {
|
||||
cause: e,
|
||||
}),
|
||||
@@ -118,13 +129,15 @@ async function scanDirectory(
|
||||
signal: AbortSignal,
|
||||
recursionLevel: number,
|
||||
) {
|
||||
if (recursionLevel > 3 || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (knownPaths.has(path)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
indexingCount++;
|
||||
|
||||
if (recursionLevel > 3 || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (knownPaths.has(path)) {
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("-UPDATE") || path.endsWith("-patch")) {
|
||||
return;
|
||||
}
|
||||
@@ -136,26 +149,24 @@ async function scanDirectory(
|
||||
for (const c of children) {
|
||||
if (c.isDirectory) {
|
||||
const childPath = await join(path, c.name);
|
||||
indexingCount++;
|
||||
setTimeout(() => {
|
||||
scanDirectory(
|
||||
childPath,
|
||||
knownPaths,
|
||||
signal,
|
||||
recursionLevel + 1,
|
||||
);
|
||||
indexingCount--;
|
||||
if (indexingCount === 0) {
|
||||
defaultStore.set(atomGameLibrary, (prev) => ({
|
||||
...prev,
|
||||
indexing: false,
|
||||
}));
|
||||
}
|
||||
}, 1);
|
||||
scanDirectory(
|
||||
childPath,
|
||||
knownPaths,
|
||||
signal,
|
||||
recursionLevel + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
indexingCount++;
|
||||
scanDirectory(
|
||||
newPath,
|
||||
knownPaths,
|
||||
abortController.signal,
|
||||
1,
|
||||
);
|
||||
indexingCount--;
|
||||
if (indexingCount === 0) {
|
||||
defaultStore.set(
|
||||
atomGameLibrary,
|
||||
(prev) => ({
|
||||
...prev,
|
||||
indexing: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if ("remove" in e.type) {
|
||||
const newPath = e.paths[0];
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
},
|
||||
|
||||
/* LANGUAGE COMPILATION OPTIONS */
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"target": "ES2023",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"module": "ESNext",
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user