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 { 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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user