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
This commit is contained in:
Simon
2025-12-14 22:05:56 +01:00
committed by GitHub
parent cd0149bb64
commit bb6f0ac931
6 changed files with 93 additions and 36 deletions
@@ -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 <div style={style}/>;
}
const game = games[gameIndex];
const game = gamesData[gameIndex];
return (
<div
@@ -77,10 +89,10 @@ export default function CoverGrid({games}: CoverGridProps) {
boxSizing: 'border-box'
}}
>
<GameCover game={game} interactive={true} size={coverHeight} lazy={true}/>
<GameCover key={game.id} game={game} interactive={true} size={height} lazy={true}/>
</div>
);
};
}, []);
// Column width function to handle the last column differently
const getColumnWidth = (index: number) => {
@@ -94,14 +106,14 @@ export default function CoverGrid({games}: CoverGridProps) {
return (
<div ref={containerRef} className="w-full">
{containerWidth > 0 && (
<Grid<{}>
<Grid<{ games: GameDto[], columnCount: number, coverHeight: number }>
columnCount={columnCount}
columnWidth={getColumnWidth}
rowCount={rowCount}
rowHeight={coverHeight + GAP}
defaultWidth={containerWidth}
cellComponent={Cell}
cellProps={{}}
cellProps={{games, columnCount, coverHeight}}
style={{overflowX: 'hidden'}}
/>
)}
@@ -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 (
<div style={{...style, paddingRight: gap}}>
<GameCover game={game} radius="sm" interactive={true}/>
<GameCover key={game.id} game={game} radius="sm" interactive={true}/>
</div>
);
};
}, []);
return (
<div className="flex flex-col mb-4">
@@ -148,7 +151,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
</div>
<div ref={containerRef} className="w-full relative overflow-hidden">
{containerWidth > 0 && (
<Grid<{}>
<Grid<{ games: GameDto[] }>
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'}}
/>
@@ -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<string | undefined>(undefined);
const containerRef = useRef<HTMLDivElement>(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,
<Image
alt={game.title}
className="z-0 object-cover aspect-12/17"
src={shouldLoad && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
src={(shouldLoad || isCached) && isImageLoaded ? `images/cover/${game.cover.id}` : blurhashUrl}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
@@ -114,4 +113,14 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
{coverContent}
</a>
) : coverContent;
}
};
// 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;
});
@@ -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
<TableHeader>
<TableColumn key="title" allowsSorting>Title</TableColumn>
<TableColumn key="library" allowsSorting>Library</TableColumn>
<TableColumn key="dateAdded" allowsSorting>Date added</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody
@@ -154,6 +158,9 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl
{libraryName(game)}
</Link>
</TableCell>
<TableCell>
{new Date(game.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip content="Add game to collection">
+28 -2
View File
@@ -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 (
<div className="w-full">
<div className="flex flex-col gap-4">
@@ -65,13 +91,13 @@ export default function HomeView() {
}
{filteredAndSortedLibraries.map((library) => (
<CoverRow key={library.id} title={library.name}
games={gamesByLibrary[library.id] || []}
games={getSortedLibraryGames(library.id)}
link={"/library/" + library.id}
/>
))}
{filteredAndSortedCollections.map((collection) => (
<CoverRow key={collection.id} title={collection.name}
games={gamesByCollection[collection.id] || []}
games={getSortedCollectionGames(collection)}
link={"/collection/" + collection.id}
/>
))}
+1 -1
View File
@@ -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 {