From bb6f0ac9314c05dccfd08128c03103215cad69db Mon Sep 17 00:00:00 2001
From: Simon <9295182+grimsi@users.noreply.github.com>
Date: Sun, 14 Dec 2025 22:05:56 +0100
Subject: [PATCH] Release 2.3.1 (#828)
* chore: bump version to v2.3.1-preview
* Fix flashing of game covers
* Change sort order of games on the start page (most recently added first)
* Add "Date added" column to table in Collection Management
---
.../components/general/covers/CoverGrid.tsx | 40 ++++++++++++-------
.../components/general/covers/CoverRow.tsx | 23 ++++++-----
.../components/general/covers/GameCover.tsx | 27 ++++++++-----
.../general/modals/CollectionGamesTable.tsx | 7 ++++
app/src/main/frontend/views/HomeView.tsx | 30 +++++++++++++-
build.gradle.kts | 2 +-
6 files changed, 93 insertions(+), 36 deletions(-)
diff --git a/app/src/main/frontend/components/general/covers/CoverGrid.tsx b/app/src/main/frontend/components/general/covers/CoverGrid.tsx
index a77a9ad..1424382 100644
--- a/app/src/main/frontend/components/general/covers/CoverGrid.tsx
+++ b/app/src/main/frontend/components/general/covers/CoverGrid.tsx
@@ -1,13 +1,21 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {GameCover} from "Frontend/components/general/covers/GameCover";
-import type {CellComponentProps} from "react-window";
import {Grid} from "react-window";
-import {useEffect, useRef, useState} from "react";
+import React, {useCallback, useEffect, useRef, useState} from "react";
interface CoverGridProps {
games: GameDto[];
}
+interface GridCellProps {
+ columnIndex: number;
+ rowIndex: number;
+ style: React.CSSProperties;
+ games: GameDto[];
+ columnCount: number;
+ coverHeight: number;
+}
+
// Constants for grid layout
const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original)
const MAX_COLUMN_WIDTH = 212; // Maximum width per item (minmax value from original)
@@ -52,20 +60,24 @@ export default function CoverGrid({games}: CoverGridProps) {
// Calculate row count
const rowCount = Math.ceil(games.length / columnCount);
+
// Cell renderer for react-window Grid
- const Cell = ({
- columnIndex,
- rowIndex,
- style
- }: CellComponentProps<{}>) => {
- const gameIndex = rowIndex * columnCount + columnIndex;
+ const Cell = useCallback(({
+ columnIndex,
+ rowIndex,
+ style,
+ games: gamesData,
+ columnCount: colCount,
+ coverHeight: height
+ }: GridCellProps) => {
+ const gameIndex = rowIndex * colCount + columnIndex;
// Return empty cell if we're past the end of the games array
- if (gameIndex >= games.length) {
+ if (gameIndex >= gamesData.length) {
return
;
}
- const game = games[gameIndex];
+ const game = gamesData[gameIndex];
return (
-
+
);
- };
+ }, []);
// Column width function to handle the last column differently
const getColumnWidth = (index: number) => {
@@ -94,14 +106,14 @@ export default function CoverGrid({games}: CoverGridProps) {
return (
{containerWidth > 0 && (
-
+
columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
- cellProps={{}}
+ cellProps={{games, columnCount, coverHeight}}
style={{overflowX: 'hidden'}}
/>
)}
diff --git a/app/src/main/frontend/components/general/covers/CoverRow.tsx b/app/src/main/frontend/components/general/covers/CoverRow.tsx
index 3216566..c28a462 100644
--- a/app/src/main/frontend/components/general/covers/CoverRow.tsx
+++ b/app/src/main/frontend/components/general/covers/CoverRow.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useRef, useState} from "react";
+import React, {useCallback, useEffect, useRef, useState} from "react";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
@@ -100,20 +100,23 @@ export function CoverRow({games, title, link}: CoverRowProps) {
const canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
- // Cell renderer for react-window Grid
- const Cell = ({columnIndex, style}: {
- ariaAttributes: { "aria-colindex": number; role: "gridcell" };
+ // Define interface for Cell props
+ interface RowCellProps {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
- }) => {
- const game = games[columnIndex];
+ games: GameDto[];
+ }
+
+ // Cell renderer for react-window Grid
+ const Cell = useCallback(({columnIndex, style, games: gamesData}: RowCellProps) => {
+ const game = gamesData[columnIndex];
return (
-
+
);
- };
+ }, []);
return (
@@ -148,7 +151,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
{containerWidth > 0 && (
-
+
gridRef={gridRef}
columnCount={games.length}
columnWidth={defaultImageWidth + gap}
@@ -157,7 +160,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
defaultHeight={defaultImageHeight}
defaultWidth={containerWidth}
cellComponent={Cell}
- cellProps={{}}
+ cellProps={{games}}
className="scrollbar-hide"
style={{overflow: 'auto'}}
/>
diff --git a/app/src/main/frontend/components/general/covers/GameCover.tsx b/app/src/main/frontend/components/general/covers/GameCover.tsx
index e17e3f4..58567e0 100644
--- a/app/src/main/frontend/components/general/covers/GameCover.tsx
+++ b/app/src/main/frontend/components/general/covers/GameCover.tsx
@@ -1,7 +1,7 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
-import {useEffect, useRef, useState} from "react";
+import {memo, useEffect, useRef, useState} from "react";
import {decode} from "blurhash";
// Cache to track which images have been loaded across component remounts
@@ -15,18 +15,17 @@ interface GameCoverProps {
lazy?: boolean;
}
-export function GameCover({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) {
+const GameCoverComponent = ({game, size = 300, radius = "sm", interactive = false, lazy = false}: GameCoverProps) => {
const [shouldLoad, setShouldLoad] = useState(!lazy);
// Check cache to see if this image has already been loaded
- const [isImageLoaded, setIsImageLoaded] = useState(
- game.cover ? loadedImagesCache.has(game.cover.id) : false
- );
+ const isCached = game.cover ? loadedImagesCache.has(game.cover.id) : false;
+ const [isImageLoaded, setIsImageLoaded] = useState(isCached);
const [blurhashUrl, setBlurhashUrl] = useState(undefined);
const containerRef = useRef(null);
// Generate blurhash placeholder image
useEffect(() => {
- if (game.cover?.blurhash) {
+ if (game.cover?.blurhash && !blurhashUrl) {
try {
// Decode blurhash to pixel data
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
@@ -49,7 +48,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
console.error('Failed to decode blurhash:', e);
}
}
- }, [game.cover?.blurhash]);
+ }, [game.cover?.blurhash, blurhashUrl]);
useEffect(() => {
if (!lazy || shouldLoad) return;
@@ -99,7 +98,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
}
@@ -114,4 +113,14 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
{coverContent}
) : coverContent;
-}
\ No newline at end of file
+};
+
+// Memoize the component to prevent unnecessary re-renders
+// Only re-render if the game ID, size, radius, interactive, or lazy props change
+export const GameCover = memo(GameCoverComponent, (prevProps, nextProps) => {
+ return prevProps.game.id === nextProps.game.id &&
+ prevProps.size === nextProps.size &&
+ prevProps.radius === nextProps.radius &&
+ prevProps.interactive === nextProps.interactive &&
+ prevProps.lazy === nextProps.lazy;
+});
diff --git a/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx b/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx
index bb9f4f5..3a51fa0 100644
--- a/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx
+++ b/app/src/main/frontend/components/general/modals/CollectionGamesTable.tsx
@@ -80,6 +80,9 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl
case "library":
cmp = (libraryName(a)).localeCompare(libraryName(b));
break;
+ case "dateAdded":
+ cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
+ break;
default:
cmp = 0;
}
@@ -130,6 +133,7 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl
Title
Library
+ Date added
Actions
+
+ {new Date(game.createdAt).toLocaleString()}
+
diff --git a/app/src/main/frontend/views/HomeView.tsx b/app/src/main/frontend/views/HomeView.tsx
index cad656f..daad9a9 100644
--- a/app/src/main/frontend/views/HomeView.tsx
+++ b/app/src/main/frontend/views/HomeView.tsx
@@ -39,6 +39,32 @@ export default function HomeView() {
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
+ // Sort games by date added (newest first) for libraries
+ const getSortedLibraryGames = (libraryId: number) => {
+ const games = gamesByLibrary[libraryId] || [];
+ return [...games].sort((a, b) => {
+ const dateA = new Date(a.createdAt).getTime();
+ const dateB = new Date(b.createdAt).getTime();
+ return dateB - dateA; // Descending order (newest first)
+ });
+ };
+
+ // Sort games by date added (newest first) for collections
+ const getSortedCollectionGames = (collection: CollectionDto) => {
+ const games = gamesByCollection[collection.id] || [];
+ const gamesAddedAt = collection.metadata?.gamesAddedAt || {};
+
+ return [...games].sort((a, b) => {
+ const dateA = gamesAddedAt[a.id.toString()]
+ ? new Date(gamesAddedAt[a.id.toString()]).getTime()
+ : 0;
+ const dateB = gamesAddedAt[b.id.toString()]
+ ? new Date(gamesAddedAt[b.id.toString()]).getTime()
+ : 0;
+ return dateB - dateA; // Descending order (newest first)
+ });
+ };
+
return (
@@ -65,13 +91,13 @@ export default function HomeView() {
}
{filteredAndSortedLibraries.map((library) => (
))}
{filteredAndSortedCollections.map((collection) => (
))}
diff --git a/build.gradle.kts b/build.gradle.kts
index de73800..e4009f6 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import java.nio.file.Files
group = "org.gameyfin"
-version = "2.3.0"
+version = "2.3.1-preview"
allprojects {
repositories {