mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
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:
@@ -1,13 +1,21 @@
|
|||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||||
import type {CellComponentProps} from "react-window";
|
|
||||||
import {Grid} from "react-window";
|
import {Grid} from "react-window";
|
||||||
import {useEffect, useRef, useState} from "react";
|
import React, {useCallback, useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
interface CoverGridProps {
|
interface CoverGridProps {
|
||||||
games: GameDto[];
|
games: GameDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GridCellProps {
|
||||||
|
columnIndex: number;
|
||||||
|
rowIndex: number;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
games: GameDto[];
|
||||||
|
columnCount: number;
|
||||||
|
coverHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Constants for grid layout
|
// Constants for grid layout
|
||||||
const MIN_COLUMN_WIDTH = 180; // Minimum width per item (minmax value from original)
|
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)
|
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
|
// Calculate row count
|
||||||
const rowCount = Math.ceil(games.length / columnCount);
|
const rowCount = Math.ceil(games.length / columnCount);
|
||||||
|
|
||||||
|
|
||||||
// Cell renderer for react-window Grid
|
// Cell renderer for react-window Grid
|
||||||
const Cell = ({
|
const Cell = useCallback(({
|
||||||
columnIndex,
|
columnIndex,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
style
|
style,
|
||||||
}: CellComponentProps<{}>) => {
|
games: gamesData,
|
||||||
const gameIndex = rowIndex * columnCount + columnIndex;
|
columnCount: colCount,
|
||||||
|
coverHeight: height
|
||||||
|
}: GridCellProps) => {
|
||||||
|
const gameIndex = rowIndex * colCount + columnIndex;
|
||||||
|
|
||||||
// Return empty cell if we're past the end of the games array
|
// 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}/>;
|
return <div style={style}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const game = games[gameIndex];
|
const game = gamesData[gameIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -77,10 +89,10 @@ export default function CoverGrid({games}: CoverGridProps) {
|
|||||||
boxSizing: 'border-box'
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Column width function to handle the last column differently
|
// Column width function to handle the last column differently
|
||||||
const getColumnWidth = (index: number) => {
|
const getColumnWidth = (index: number) => {
|
||||||
@@ -94,14 +106,14 @@ export default function CoverGrid({games}: CoverGridProps) {
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full">
|
<div ref={containerRef} className="w-full">
|
||||||
{containerWidth > 0 && (
|
{containerWidth > 0 && (
|
||||||
<Grid<{}>
|
<Grid<{ games: GameDto[], columnCount: number, coverHeight: number }>
|
||||||
columnCount={columnCount}
|
columnCount={columnCount}
|
||||||
columnWidth={getColumnWidth}
|
columnWidth={getColumnWidth}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
rowHeight={coverHeight + GAP}
|
rowHeight={coverHeight + GAP}
|
||||||
defaultWidth={containerWidth}
|
defaultWidth={containerWidth}
|
||||||
cellComponent={Cell}
|
cellComponent={Cell}
|
||||||
cellProps={{}}
|
cellProps={{games, columnCount, coverHeight}}
|
||||||
style={{overflowX: 'hidden'}}
|
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 {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import {CaretLeftIcon, CaretRightIcon} from "@phosphor-icons/react";
|
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 canScrollLeft = scrollPosition > 1; // Allow small margin for floating point issues
|
||||||
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
|
const canScrollRight = scrollPosition < maxScroll - 1 && maxScroll > 0;
|
||||||
|
|
||||||
// Cell renderer for react-window Grid
|
// Define interface for Cell props
|
||||||
const Cell = ({columnIndex, style}: {
|
interface RowCellProps {
|
||||||
ariaAttributes: { "aria-colindex": number; role: "gridcell" };
|
|
||||||
columnIndex: number;
|
columnIndex: number;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
}) => {
|
games: GameDto[];
|
||||||
const game = games[columnIndex];
|
}
|
||||||
|
|
||||||
|
// Cell renderer for react-window Grid
|
||||||
|
const Cell = useCallback(({columnIndex, style, games: gamesData}: RowCellProps) => {
|
||||||
|
const game = gamesData[columnIndex];
|
||||||
return (
|
return (
|
||||||
<div style={{...style, paddingRight: gap}}>
|
<div style={{...style, paddingRight: gap}}>
|
||||||
<GameCover game={game} radius="sm" interactive={true}/>
|
<GameCover key={game.id} game={game} radius="sm" interactive={true}/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col mb-4">
|
<div className="flex flex-col mb-4">
|
||||||
@@ -148,7 +151,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="w-full relative overflow-hidden">
|
<div ref={containerRef} className="w-full relative overflow-hidden">
|
||||||
{containerWidth > 0 && (
|
{containerWidth > 0 && (
|
||||||
<Grid<{}>
|
<Grid<{ games: GameDto[] }>
|
||||||
gridRef={gridRef}
|
gridRef={gridRef}
|
||||||
columnCount={games.length}
|
columnCount={games.length}
|
||||||
columnWidth={defaultImageWidth + gap}
|
columnWidth={defaultImageWidth + gap}
|
||||||
@@ -157,7 +160,7 @@ export function CoverRow({games, title, link}: CoverRowProps) {
|
|||||||
defaultHeight={defaultImageHeight}
|
defaultHeight={defaultImageHeight}
|
||||||
defaultWidth={containerWidth}
|
defaultWidth={containerWidth}
|
||||||
cellComponent={Cell}
|
cellComponent={Cell}
|
||||||
cellProps={{}}
|
cellProps={{games}}
|
||||||
className="scrollbar-hide"
|
className="scrollbar-hide"
|
||||||
style={{overflow: 'auto'}}
|
style={{overflow: 'auto'}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import {Image} from "@heroui/react";
|
import {Image} from "@heroui/react";
|
||||||
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
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";
|
import {decode} from "blurhash";
|
||||||
|
|
||||||
// Cache to track which images have been loaded across component remounts
|
// Cache to track which images have been loaded across component remounts
|
||||||
@@ -15,18 +15,17 @@ interface GameCoverProps {
|
|||||||
lazy?: boolean;
|
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);
|
const [shouldLoad, setShouldLoad] = useState(!lazy);
|
||||||
// Check cache to see if this image has already been loaded
|
// Check cache to see if this image has already been loaded
|
||||||
const [isImageLoaded, setIsImageLoaded] = useState(
|
const isCached = game.cover ? loadedImagesCache.has(game.cover.id) : false;
|
||||||
game.cover ? loadedImagesCache.has(game.cover.id) : false
|
const [isImageLoaded, setIsImageLoaded] = useState(isCached);
|
||||||
);
|
|
||||||
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
|
const [blurhashUrl, setBlurhashUrl] = useState<string | undefined>(undefined);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Generate blurhash placeholder image
|
// Generate blurhash placeholder image
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (game.cover?.blurhash) {
|
if (game.cover?.blurhash && !blurhashUrl) {
|
||||||
try {
|
try {
|
||||||
// Decode blurhash to pixel data
|
// Decode blurhash to pixel data
|
||||||
const pixels = decode(game.cover.blurhash, 32, 45); // Small size for placeholder
|
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);
|
console.error('Failed to decode blurhash:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [game.cover?.blurhash]);
|
}, [game.cover?.blurhash, blurhashUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lazy || shouldLoad) return;
|
if (!lazy || shouldLoad) return;
|
||||||
@@ -99,7 +98,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
|
|||||||
<Image
|
<Image
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="z-0 object-cover aspect-12/17"
|
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}
|
radius={radius}
|
||||||
height={size}
|
height={size}
|
||||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||||
@@ -114,4 +113,14 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false,
|
|||||||
{coverContent}
|
{coverContent}
|
||||||
</a>
|
</a>
|
||||||
) : coverContent;
|
) : 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":
|
case "library":
|
||||||
cmp = (libraryName(a)).localeCompare(libraryName(b));
|
cmp = (libraryName(a)).localeCompare(libraryName(b));
|
||||||
break;
|
break;
|
||||||
|
case "dateAdded":
|
||||||
|
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
cmp = 0;
|
cmp = 0;
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,7 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn key="title" allowsSorting>Title</TableColumn>
|
<TableColumn key="title" allowsSorting>Title</TableColumn>
|
||||||
<TableColumn key="library" allowsSorting>Library</TableColumn>
|
<TableColumn key="library" allowsSorting>Library</TableColumn>
|
||||||
|
<TableColumn key="dateAdded" allowsSorting>Date added</TableColumn>
|
||||||
<TableColumn width={1}>Actions</TableColumn>
|
<TableColumn width={1}>Actions</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody
|
<TableBody
|
||||||
@@ -154,6 +158,9 @@ export default function CollectionGamesTable({collectionId}: CollectionGamesTabl
|
|||||||
{libraryName(game)}
|
{libraryName(game)}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(game.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Tooltip content="Add game to collection">
|
<Tooltip content="Add game to collection">
|
||||||
|
|||||||
@@ -39,6 +39,32 @@ export default function HomeView() {
|
|||||||
|
|
||||||
}, [librariesState.sorted, collectionsState.sorted, gamesByLibrary, gamesByCollection]);
|
}, [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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -65,13 +91,13 @@ export default function HomeView() {
|
|||||||
}
|
}
|
||||||
{filteredAndSortedLibraries.map((library) => (
|
{filteredAndSortedLibraries.map((library) => (
|
||||||
<CoverRow key={library.id} title={library.name}
|
<CoverRow key={library.id} title={library.name}
|
||||||
games={gamesByLibrary[library.id] || []}
|
games={getSortedLibraryGames(library.id)}
|
||||||
link={"/library/" + library.id}
|
link={"/library/" + library.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{filteredAndSortedCollections.map((collection) => (
|
{filteredAndSortedCollections.map((collection) => (
|
||||||
<CoverRow key={collection.id} title={collection.name}
|
<CoverRow key={collection.id} title={collection.name}
|
||||||
games={gamesByCollection[collection.id] || []}
|
games={getSortedCollectionGames(collection)}
|
||||||
link={"/collection/" + collection.id}
|
link={"/collection/" + collection.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
group = "org.gameyfin"
|
group = "org.gameyfin"
|
||||||
version = "2.3.0"
|
version = "2.3.1-preview"
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
Reference in New Issue
Block a user