ci: performance optimizing in library

This commit is contained in:
Moyasee
2025-11-08 14:28:54 +02:00
parent 3bef0c9269
commit c3a4990a50
3 changed files with 369 additions and 326 deletions

View File

@@ -12,14 +12,18 @@ import {
XIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useCallback, useState } from "react";
import { useCallback, memo, useMemo } from "react";
import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { GameContextMenu } from "@renderer/components";
import { logger } from "@renderer/logger";
import "./library-game-card-large.scss";
interface LibraryGameCardLargeProps {
game: LibraryGame;
onContextMenu: (
game: LibraryGame,
position: { x: number; y: number }
) => void;
}
const getImageWithCustomPriority = (
@@ -30,17 +34,14 @@ const getImageWithCustomPriority = (
return customUrl || originalUrl || fallbackUrl || "";
};
export function LibraryGameCardLarge({
export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
game,
onContextMenu,
}: Readonly<LibraryGameCardLargeProps>) {
const { t } = useTranslation("library");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const { lastPacket } = useDownload();
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
}>({ visible: false, position: { x: 0, y: 0 } });
const isGameDownloading =
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
@@ -84,196 +85,193 @@ export function LibraryGameCardLarge({
try {
await handleCloseGame();
} catch (e) {
console.error(e);
logger.error(e);
}
return;
}
try {
await handlePlayGame();
} catch (err) {
console.error(err);
logger.error(err);
try {
handleOpenDownloadOptions();
} catch (e) {
console.error(e);
logger.error(e);
}
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
});
};
const handleMenuButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setContextMenu({
visible: true,
position: {
x: e.currentTarget.getBoundingClientRect().right,
y: e.currentTarget.getBoundingClientRect().bottom,
},
});
};
const handleCloseContextMenu = () => {
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
};
// Use libraryHeroImageUrl as background, fallback to libraryImageUrl
const backgroundImage = getImageWithCustomPriority(
game.libraryHeroImageUrl,
game.libraryImageUrl,
game.iconUrl
const handleContextMenuClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(game, { x: e.clientX, y: e.clientY });
},
[game, onContextMenu]
);
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onContextMenu(game, { x: rect.right, y: rect.bottom });
},
[game, onContextMenu]
);
const backgroundImage = useMemo(
() =>
getImageWithCustomPriority(
game.libraryHeroImageUrl,
game.libraryImageUrl,
game.iconUrl
),
[game.libraryHeroImageUrl, game.libraryImageUrl, game.iconUrl]
);
const backgroundStyle = useMemo(
() => ({ backgroundImage: `url(${backgroundImage})` }),
[backgroundImage]
);
const achievementBarStyle = useMemo(
() => ({
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}),
[game.unlockedAchievementCount, game.achievementCount]
);
// For logo, check if logoImageUrl exists (similar to game details page)
const logoImage = game.logoImageUrl;
return (
<>
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenu}
>
<div
className="library-game-card-large__background"
style={{ backgroundImage: `url(${backgroundImage})` }}
/>
<div className="library-game-card-large__gradient" />
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div
className="library-game-card-large__background"
style={backgroundStyle}
/>
<div className="library-game-card-large__gradient" />
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card-large__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card-large__playtime-text">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
</div>
<button
type="button"
className="library-game-card-large__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</div>
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
alt={game.title}
className="library-game-card-large__logo"
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card-large__manual-playtime"
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>
<ClockIcon size={11} />
)}
<span className="library-game-card-large__playtime-text">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
</div>
<button
type="button"
className="library-game-card-large__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</div>
<div className="library-game-card-large__info-bar">
{/* Achievements section */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card-large__achievements">
<div className="library-game-card-large__achievement-header">
<div className="library-game-card-large__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
alt={game.title}
className="library-game-card-large__logo"
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>
)}
</div>
<div className="library-game-card-large__info-bar">
{/* Achievements section */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card-large__achievements">
<div className="library-game-card-large__achievement-header">
<div className="library-game-card-large__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<div className="library-game-card-large__achievement-progress">
<div
className="library-game-card-large__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
)}
<button
type="button"
className="library-game-card-large__action-button"
onClick={handleActionClick}
>
{(() => {
if (isGameDownloading) {
return (
<>
<DownloadIcon
size={16}
className="library-game-card-large__action-icon--downloading"
/>
{t("downloading")}
</>
);
}
if (isGameRunning) {
return (
<>
<XIcon size={16} />
{t("close")}
</>
);
}
if (game.executablePath) {
return (
<>
<PlayIcon size={16} />
{t("play")}
</>
);
}
<div className="library-game-card-large__achievement-progress">
<div
className="library-game-card-large__achievement-bar"
style={achievementBarStyle}
/>
</div>
</div>
)}
<button
type="button"
className="library-game-card-large__action-button"
onClick={handleActionClick}
>
{(() => {
if (isGameDownloading) {
return (
<>
<DownloadIcon size={16} />
{t("download")}
<DownloadIcon
size={16}
className="library-game-card-large__action-icon--downloading"
/>
{t("downloading")}
</>
);
})()}
</button>
</div>
}
if (isGameRunning) {
return (
<>
<XIcon size={16} />
{t("close")}
</>
);
}
if (game.executablePath) {
return (
<>
<PlayIcon size={16} />
{t("play")}
</>
);
}
return (
<>
<DownloadIcon size={16} />
{t("download")}
</>
);
})()}
</button>
</div>
</button>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
</div>
</button>
);
}
});

View File

@@ -1,7 +1,7 @@
import { LibraryGame } from "@types";
import { useFormat } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { useCallback, useState } from "react";
import { useCallback, memo } from "react";
import { buildGameDetailsPath } from "@renderer/helpers";
import {
ClockIcon,
@@ -10,30 +10,30 @@ import {
TrophyIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next";
import { GameContextMenu } from "@renderer/components";
import "./library-game-card.scss";
interface LibraryGameCardProps {
game: LibraryGame;
onMouseEnter: () => void;
onMouseLeave: () => void;
onContextMenu: (
game: LibraryGame,
position: { x: number; y: number }
) => void;
onShowTooltip?: (gameId: string) => void;
onHideTooltip?: () => void;
}
export function LibraryGameCard({
export const LibraryGameCard = memo(function LibraryGameCard({
game,
onMouseEnter,
onMouseLeave,
onContextMenu,
}: Readonly<LibraryGameCardProps>) {
const { t } = useTranslation("library");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
}>({ visible: false, position: { x: 0, y: 0 } });
const formatPlayTime = useCallback(
(playTimeInMilliseconds = 0, isShort = false) => {
@@ -60,30 +60,23 @@ export function LibraryGameCard({
navigate(buildGameDetailsPath(game));
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const handleContextMenuClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(game, { x: e.clientX, y: e.clientY });
},
[game, onContextMenu]
);
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
});
};
const handleMenuButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setContextMenu({
visible: true,
position: {
x: e.currentTarget.getBoundingClientRect().right,
y: e.currentTarget.getBoundingClientRect().bottom,
},
});
};
const handleCloseContextMenu = () => {
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
};
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onContextMenu(game, { x: rect.right, y: rect.bottom });
},
[game, onContextMenu]
);
const coverImage =
game.coverImageUrl ??
@@ -93,110 +86,85 @@ export function LibraryGameCard({
undefined;
return (
<>
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={isTooltipHovered ? undefined : game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenu}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div
className="library-game-card__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card__playtime-long">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
<span className="library-game-card__playtime-short">
{formatPlayTime(game.playTimeInMilliseconds, true)}
</span>
</div>
<button
type="button"
className="library-game-card__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div className="library-game-card__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card__playtime-long">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
<span className="library-game-card__playtime-short">
{formatPlayTime(game.playTimeInMilliseconds, true)}
</span>
</div>
{/* Achievements section - shown on hover */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card__achievements">
<div className="library-game-card__achievement-header">
<div className="library-game-card__achievements-gap">
<TrophyIcon
size={13}
className="library-game-card__achievement-trophy"
/>
<span className="library-game-card__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card__achievement-progress">
<div
className="library-game-card__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
<button
type="button"
className="library-game-card__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</div>
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
/>
</button>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
{/* Achievements section - shown on hover */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card__achievements">
<div className="library-game-card__achievement-header">
<div className="library-game-card__achievements-gap">
<TrophyIcon
size={13}
className="library-game-card__achievement-trophy"
/>
<span className="library-game-card__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card__achievement-progress">
<div
className="library-game-card__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
/>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
</button>
);
}
});

View File

@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { LibraryGame } from "@types";
import { GameContextMenu } from "@renderer/components";
import VirtualList from "rc-virtual-list";
import { LibraryGameCard } from "./library-game-card";
// detailed view removed — keep file if needed later
import { LibraryGameCardLarge } from "./library-game-card-large";
import { ViewOptions, ViewMode } from "./view-options";
import { FilterOptions, FilterOption } from "./filter-options";
@@ -19,6 +21,14 @@ export default function Library() {
const [viewMode, setViewMode] = useState<ViewMode>("compact");
const [filterBy, setFilterBy] = useState<FilterOption>("all");
const [containerHeight, setContainerHeight] = useState(800);
const [contextMenu, setContextMenu] = useState<{
game: LibraryGame | null;
visible: boolean;
position: { x: number; y: number };
}>({ game: null, visible: false, position: { x: 0, y: 0 } });
const containerRef = useRef<HTMLElement>(null);
const searchQuery = useAppSelector((state) => state.library.searchQuery);
const dispatch = useAppDispatch();
const { t } = useTranslation("library");
@@ -47,13 +57,37 @@ export default function Library() {
};
}, [dispatch, t, updateLibrary]);
const handleOnMouseEnterGameCard = () => {
// Optional: pause animations if needed
};
useEffect(() => {
const updateHeight = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setContainerHeight(window.innerHeight - rect.top);
}
};
const handleOnMouseLeaveGameCard = () => {
updateHeight();
window.addEventListener("resize", updateHeight);
return () => window.removeEventListener("resize", updateHeight);
}, []);
const handleOnMouseEnterGameCard = useCallback(() => {
// Optional: pause animations if needed
}, []);
const handleOnMouseLeaveGameCard = useCallback(() => {
// Optional: resume animations if needed
};
}, []);
const handleOpenContextMenu = useCallback(
(game: LibraryGame, position: { x: number; y: number }) => {
setContextMenu({ game, visible: true, position });
},
[]
);
const handleCloseContextMenu = useCallback(() => {
setContextMenu({ game: null, visible: false, position: { x: 0, y: 0 } });
}, []);
const filteredLibrary = useMemo(() => {
let filtered;
@@ -102,21 +136,33 @@ export default function Library() {
});
}, [library, filterBy, searchQuery]);
// No sorting for now — rely on filteredLibrary
const sortedLibrary = filteredLibrary;
// Calculate counts for filters
const allGamesCount = library.length;
const favouritedCount = library.filter((game) => game.favorite).length;
const newGamesCount = library.filter(
(game) => (game.playTimeInMilliseconds || 0) === 0
).length;
const top10Count = Math.min(10, library.length);
const filterCounts = useMemo(() => {
const allGamesCount = library.length;
let favouritedCount = 0;
let newGamesCount = 0;
for (const game of library) {
if (game.favorite) favouritedCount++;
if ((game.playTimeInMilliseconds || 0) === 0) newGamesCount++;
}
return {
allGamesCount,
favouritedCount,
newGamesCount,
top10Count: Math.min(10, allGamesCount),
};
}, [library]);
const hasGames = library.length > 0;
const itemHeight =
viewMode === "large" ? 200 : viewMode === "grid" ? 240 : 180;
return (
<section className="library__content">
<section className="library__content" ref={containerRef}>
{hasGames && (
<div className="library__page-header">
<div className="library__controls-row">
@@ -124,10 +170,10 @@ export default function Library() {
<FilterOptions
filterBy={filterBy}
onFilterChange={setFilterBy}
allGamesCount={allGamesCount}
favouritedCount={favouritedCount}
newGamesCount={newGamesCount}
top10Count={top10Count}
allGamesCount={filterCounts.allGamesCount}
favouritedCount={filterCounts.favouritedCount}
newGamesCount={filterCounts.newGamesCount}
top10Count={filterCounts.top10Count}
/>
</div>
@@ -148,17 +194,38 @@ export default function Library() {
</div>
)}
{hasGames && viewMode === "large" && (
{hasGames && sortedLibrary.length > 50 && viewMode === "large" && (
<div className="library__games-list library__games-list--large">
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
/>
))}
<VirtualList
data={sortedLibrary}
height={containerHeight}
itemHeight={itemHeight}
itemKey={(game) => `${game.shop}-${game.objectId}`}
>
{(game) => (
<LibraryGameCardLarge
game={game}
onContextMenu={handleOpenContextMenu}
/>
)}
</VirtualList>
</div>
)}
{hasGames &&
(sortedLibrary.length <= 50 || viewMode !== "large") &&
viewMode === "large" && (
<div className="library__games-list library__games-list--large">
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
onContextMenu={handleOpenContextMenu}
/>
))}
</div>
)}
{hasGames && viewMode !== "large" && (
<ul className={`library__games-grid library__games-grid--${viewMode}`}>
{sortedLibrary.map((game) => (
@@ -170,11 +237,21 @@ export default function Library() {
game={game}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
onContextMenu={handleOpenContextMenu}
/>
</li>
))}
</ul>
)}
{contextMenu.game && (
<GameContextMenu
game={contextMenu.game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
)}
</section>
);
}