feat(library): load resized CDN covers with ThumbHash placeholders (#3013)
* feat(library): load resized CDN covers with ThumbHash placeholders Library and profile responses now carry an images map with resized WebP copies of each artwork URL and a ThumbHash. Cards use it to load the copy that fits instead of the full-size Steam image, and show the decoded ThumbHash until the image loads, instead of a blank box. - Grid and profile cards use a srcset of the cover widths. - The large library card loads its hero and logo at the width it needs. - A broken copy falls back to the original URL. - Decoded placeholders are cached, up to 1,000 entries. - Games without a copy render exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014J6EZ4iUeZf8tKJxAZ18TP * fix(library): pick a larger hero when the window grows The large card read the window width only while rendering. Because the card is memoized, enlarging the window kept the smaller hero copy. The card now subscribes to resizes and re-renders only when the picked copy changes. Also stub the new image hook and helpers in the large-card achievement test, which failed to load them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014J6EZ4iUeZf8tKJxAZ18TP --------- Co-authored-by: Chubby Granny Chaser <hydralauncher@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
57441a2885
commit
6ecb7ed6ed
15 changed files with 380 additions and 12 deletions
|
|
@ -106,6 +106,7 @@
|
|||
"string-argv": "^0.3.2",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tar": "^7.5.21",
|
||||
"thumbhash": "0.1.1",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"user-agents": "^1.1.387",
|
||||
"uuid": "^14.0.0",
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ const buildRemoteGameShopAssets = (
|
|||
iconUrl: remoteGame.iconUrl,
|
||||
logoPosition: remoteGame.logoPosition,
|
||||
downloadSources: remoteGame.downloadSources,
|
||||
images: { ...localShopAssets?.images, ...remoteGame.images },
|
||||
};
|
||||
return (remoteGame.catalogueGame ?? localGame?.catalogueGame)?.shop ===
|
||||
"steam"
|
||||
|
|
@ -458,6 +459,7 @@ const mergeRemoteGamesChunk = async (
|
|||
logoImageUrl: assets.logoImageUrl,
|
||||
logoPosition: assets.logoPosition,
|
||||
downloadSources: assets.downloadSources,
|
||||
images: assets.images,
|
||||
},
|
||||
{ sublevel: gamesShopAssetsSublevel }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
import { ImageIcon } from "@primer/octicons-react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CatalogueImages } from "@types";
|
||||
|
||||
import {
|
||||
isAnimatedCoverCandidate,
|
||||
useAnimatedSourceWarmup,
|
||||
useAppSelector,
|
||||
useCatalogueImage,
|
||||
useCoverPoster,
|
||||
} from "@renderer/hooks";
|
||||
import {
|
||||
getCatalogueImageSrcSet,
|
||||
pickCatalogueImageUrl,
|
||||
} from "@renderer/helpers/catalogue-images";
|
||||
import { getVerticalCoverCardImageSources } from "./vertical-cover-card-image-sources";
|
||||
|
||||
import "./vertical-cover-card.scss";
|
||||
|
||||
const COVER_DISPLAY_WIDTH = 300;
|
||||
|
||||
export interface VerticalCoverCardProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
gameTitle: string;
|
||||
coverImageUrls: readonly (string | null | undefined)[];
|
||||
catalogueImages?: CatalogueImages;
|
||||
children?: ReactNode;
|
||||
useClassicsLayout?: boolean;
|
||||
showTitleTooltip?: boolean;
|
||||
|
|
@ -24,6 +33,7 @@ export interface VerticalCoverCardProps
|
|||
export function VerticalCoverCard({
|
||||
gameTitle,
|
||||
coverImageUrls,
|
||||
catalogueImages,
|
||||
children,
|
||||
useClassicsLayout = false,
|
||||
showTitleTooltip = true,
|
||||
|
|
@ -43,7 +53,13 @@ export function VerticalCoverCard({
|
|||
);
|
||||
const autoplayAnimatedArtwork =
|
||||
userPreferences?.autoplayAnimatedArtwork ?? false;
|
||||
const isAnimatedCover = isAnimatedCoverCandidate(coverImageUrl);
|
||||
const catalogueImage = useCatalogueImage(
|
||||
useClassicsLayout ? null : coverImageUrl,
|
||||
catalogueImages
|
||||
);
|
||||
const coverVariants = catalogueImage.variants;
|
||||
const isAnimatedCover =
|
||||
!coverVariants && isAnimatedCoverCandidate(coverImageUrl);
|
||||
const coverPoster = useCoverPoster(coverImageUrl, isAnimatedCover);
|
||||
const shouldHoldFrame =
|
||||
isAnimatedCover && !isHovered && !autoplayAnimatedArtwork;
|
||||
|
|
@ -62,6 +78,8 @@ export function VerticalCoverCard({
|
|||
}, [imageSourcesKey]);
|
||||
|
||||
const handleImageError = () => {
|
||||
if (catalogueImage.markFailed()) return;
|
||||
|
||||
if (!coverImageUrl || failedImageSourcesRef.current.has(coverImageUrl)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -109,11 +127,21 @@ export function VerticalCoverCard({
|
|||
|
||||
return (
|
||||
<img
|
||||
src={displayCoverUrl}
|
||||
src={
|
||||
coverVariants
|
||||
? pickCatalogueImageUrl(coverVariants, COVER_DISPLAY_WIDTH)
|
||||
: displayCoverUrl
|
||||
}
|
||||
srcSet={
|
||||
coverVariants ? getCatalogueImageSrcSet(coverVariants) : undefined
|
||||
}
|
||||
sizes={coverVariants ? `auto, ${COVER_DISPLAY_WIDTH}px` : undefined}
|
||||
style={catalogueImage.placeholderStyle}
|
||||
alt={gameTitle}
|
||||
className="vertical-cover-card__image"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={catalogueImage.markLoaded}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
82
src/renderer/src/helpers/catalogue-images.test.ts
Normal file
82
src/renderer/src/helpers/catalogue-images.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
decodeCatalogueThumbHash,
|
||||
findCatalogueImageVariants,
|
||||
getCatalogueImageDensitySrcSet,
|
||||
getCatalogueImageSrcSet,
|
||||
pickCatalogueImageUrl,
|
||||
} from "./catalogue-images.js";
|
||||
|
||||
const coverUrl =
|
||||
"https://shared.steamstatic.com/store_item_assets/steam/apps/271590/library_600x900_2x.jpg";
|
||||
const cover = {
|
||||
base: "https://cdn.losbroxas.org/img/v1/bba708cf29c2a9192a9294f584475d12",
|
||||
widths: [600, 200, 400],
|
||||
thumbHash: "migKDQIZN4uJB6Zol4emhcFkz471",
|
||||
};
|
||||
|
||||
describe("findCatalogueImageVariants", () => {
|
||||
it("looks variants up by the original URL", () => {
|
||||
assert.equal(
|
||||
findCatalogueImageVariants({ [coverUrl]: cover }, ` ${coverUrl} `),
|
||||
cover
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores missing URLs and entries without widths", () => {
|
||||
assert.equal(findCatalogueImageVariants({ [coverUrl]: cover }, null), null);
|
||||
assert.equal(
|
||||
findCatalogueImageVariants({ [coverUrl]: cover }, "https://x/y.jpg"),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
findCatalogueImageVariants(
|
||||
{ [coverUrl]: { ...cover, widths: [] } },
|
||||
coverUrl
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCatalogueImageUrl", () => {
|
||||
it("picks the smallest width that covers the target", () => {
|
||||
assert.equal(pickCatalogueImageUrl(cover, 250), `${cover.base}/w400.webp`);
|
||||
assert.equal(pickCatalogueImageUrl(cover, 200), `${cover.base}/w200.webp`);
|
||||
});
|
||||
|
||||
it("falls back to the largest width", () => {
|
||||
assert.equal(pickCatalogueImageUrl(cover, 2000), `${cover.base}/w600.webp`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("srcset builders", () => {
|
||||
it("lists widths in ascending order", () => {
|
||||
assert.equal(
|
||||
getCatalogueImageSrcSet(cover),
|
||||
`${cover.base}/w200.webp 200w, ${cover.base}/w400.webp 400w, ${cover.base}/w600.webp 600w`
|
||||
);
|
||||
});
|
||||
|
||||
it("picks one width per pixel density", () => {
|
||||
assert.equal(
|
||||
getCatalogueImageDensitySrcSet(cover, 300),
|
||||
`${cover.base}/w400.webp 1x, ${cover.base}/w600.webp 2x`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeCatalogueThumbHash", () => {
|
||||
it("decodes a ThumbHash into a PNG data URL", () => {
|
||||
const placeholder = decodeCatalogueThumbHash(cover.thumbHash);
|
||||
assert.ok(placeholder?.startsWith("data:image/png;base64,"));
|
||||
assert.equal(decodeCatalogueThumbHash(cover.thumbHash), placeholder);
|
||||
});
|
||||
|
||||
it("returns null for empty or invalid hashes", () => {
|
||||
assert.equal(decodeCatalogueThumbHash(null), null);
|
||||
assert.equal(decodeCatalogueThumbHash("%%%"), null);
|
||||
});
|
||||
});
|
||||
73
src/renderer/src/helpers/catalogue-images.ts
Normal file
73
src/renderer/src/helpers/catalogue-images.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { thumbHashToDataURL } from "thumbhash";
|
||||
import type { CatalogueImages, CatalogueImageVariants } from "@types";
|
||||
|
||||
const MAX_CACHED_PLACEHOLDERS = 1000;
|
||||
const placeholderCache = new Map<string, string | null>();
|
||||
|
||||
export const findCatalogueImageVariants = (
|
||||
images: CatalogueImages | null | undefined,
|
||||
url: string | null | undefined
|
||||
): CatalogueImageVariants | null => {
|
||||
const variants = url ? images?.[url.trim()] : undefined;
|
||||
return variants?.base && variants.widths.length > 0 ? variants : null;
|
||||
};
|
||||
|
||||
const sortedWidths = (variants: CatalogueImageVariants) =>
|
||||
[...variants.widths].sort((a, b) => a - b);
|
||||
|
||||
export const getCatalogueImageUrl = (
|
||||
variants: CatalogueImageVariants,
|
||||
width: number
|
||||
) => `${variants.base}/w${width}.webp`;
|
||||
|
||||
export const pickCatalogueImageUrl = (
|
||||
variants: CatalogueImageVariants,
|
||||
targetWidth: number
|
||||
) => {
|
||||
const widths = sortedWidths(variants);
|
||||
const width =
|
||||
widths.find((candidate) => candidate >= targetWidth) ?? widths.at(-1)!;
|
||||
return getCatalogueImageUrl(variants, width);
|
||||
};
|
||||
|
||||
export const getCatalogueImageSrcSet = (variants: CatalogueImageVariants) =>
|
||||
sortedWidths(variants)
|
||||
.map((width) => `${getCatalogueImageUrl(variants, width)} ${width}w`)
|
||||
.join(", ");
|
||||
|
||||
export const getCatalogueImageDensitySrcSet = (
|
||||
variants: CatalogueImageVariants,
|
||||
displayWidth: number
|
||||
) =>
|
||||
[1, 2]
|
||||
.map(
|
||||
(density) =>
|
||||
`${pickCatalogueImageUrl(variants, displayWidth * density)} ${density}x`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
export const decodeCatalogueThumbHash = (
|
||||
thumbHash: string | null | undefined
|
||||
): string | null => {
|
||||
if (!thumbHash) return null;
|
||||
|
||||
const cached = placeholderCache.get(thumbHash);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let placeholder: string | null = null;
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(thumbHash), (char) =>
|
||||
char.charCodeAt(0)
|
||||
);
|
||||
placeholder = thumbHashToDataURL(bytes);
|
||||
} catch {
|
||||
placeholder = null;
|
||||
}
|
||||
|
||||
if (placeholderCache.size >= MAX_CACHED_PLACEHOLDERS) {
|
||||
const oldest = placeholderCache.keys().next().value;
|
||||
if (oldest !== undefined) placeholderCache.delete(oldest);
|
||||
}
|
||||
placeholderCache.set(thumbHash, placeholder);
|
||||
return placeholder;
|
||||
};
|
||||
|
|
@ -28,6 +28,7 @@ export * from "./use-retroarch-scan";
|
|||
export * from "./use-game-artwork-grid";
|
||||
export * from "./game-artwork-utils";
|
||||
export * from "./use-cover-poster";
|
||||
export * from "./use-catalogue-image";
|
||||
export * from "./use-processed-image";
|
||||
export * from "./use-file-icons";
|
||||
export * from "./use-drag-text-selection";
|
||||
|
|
|
|||
85
src/renderer/src/hooks/use-catalogue-image.ts
Normal file
85
src/renderer/src/hooks/use-catalogue-image.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CatalogueImages, CatalogueImageVariants } from "@types";
|
||||
import {
|
||||
decodeCatalogueThumbHash,
|
||||
findCatalogueImageVariants,
|
||||
pickCatalogueImageUrl,
|
||||
} from "@renderer/helpers/catalogue-images";
|
||||
|
||||
const subscribeToResize = (onResize: () => void) => {
|
||||
globalThis.window.addEventListener("resize", onResize);
|
||||
return () => globalThis.window.removeEventListener("resize", onResize);
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the copy that covers the window's width in device pixels. The
|
||||
* component re-renders only when a resize changes the picked copy.
|
||||
*/
|
||||
export function useViewportCatalogueImageUrl(
|
||||
variants: CatalogueImageVariants | null
|
||||
) {
|
||||
const getSnapshot = useCallback(
|
||||
() =>
|
||||
variants
|
||||
? pickCatalogueImageUrl(
|
||||
variants,
|
||||
globalThis.window.innerWidth * globalThis.window.devicePixelRatio
|
||||
)
|
||||
: null,
|
||||
[variants]
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribeToResize, getSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the resized CDN copy of an artwork URL. Callers fall back to the
|
||||
* original URL once `markFailed` returns true for a broken copy.
|
||||
*/
|
||||
export function useCatalogueImage(
|
||||
url: string | null | undefined,
|
||||
images: CatalogueImages | null | undefined
|
||||
) {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
|
||||
|
||||
const found = findCatalogueImageVariants(images, url);
|
||||
const variants = found && failedUrl !== url ? found : null;
|
||||
const isLoaded = Boolean(url) && loadedUrl === url;
|
||||
const placeholder = useMemo(
|
||||
() => decodeCatalogueThumbHash(variants?.thumbHash),
|
||||
[variants?.thumbHash]
|
||||
);
|
||||
|
||||
const placeholderStyle = useMemo<CSSProperties | undefined>(
|
||||
() =>
|
||||
placeholder && !isLoaded
|
||||
? {
|
||||
backgroundImage: `url("${placeholder}")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined,
|
||||
[placeholder, isLoaded]
|
||||
);
|
||||
|
||||
const markLoaded = useCallback(() => {
|
||||
if (url) setLoadedUrl(url);
|
||||
}, [url]);
|
||||
|
||||
const markFailed = useCallback(() => {
|
||||
if (!variants || !url) return false;
|
||||
setFailedUrl(url);
|
||||
return true;
|
||||
}, [variants, url]);
|
||||
|
||||
return {
|
||||
variants,
|
||||
placeholder,
|
||||
placeholderStyle,
|
||||
isLoaded,
|
||||
markLoaded,
|
||||
markFailed,
|
||||
};
|
||||
}
|
||||
|
|
@ -53,8 +53,18 @@ function renderCard(
|
|||
useCoverPoster: () => undefined,
|
||||
isAnimatedCoverCandidate: () => false,
|
||||
useAnimatedSourceWarmup: () => {},
|
||||
useCatalogueImage: () => ({
|
||||
variants: null,
|
||||
placeholderStyle: undefined,
|
||||
markLoaded: () => {},
|
||||
markFailed: () => false,
|
||||
}),
|
||||
useFormat: () => ({ numberFormatter: new Intl.NumberFormat("en") }),
|
||||
},
|
||||
"@renderer/helpers/catalogue-images": {
|
||||
getCatalogueImageSrcSet: () => "",
|
||||
pickCatalogueImageUrl: () => "",
|
||||
},
|
||||
"@shared": { getDisplayedPlayTimeInMilliseconds: () => 0 },
|
||||
"./library-category": libraryCategory,
|
||||
"@renderer/helpers": {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ it("refreshes grouped progress on member changes and preserves known offline tot
|
|||
useCoverPoster: () => undefined,
|
||||
isAnimatedCoverCandidate: () => false,
|
||||
useAnimatedSourceWarmup: () => {},
|
||||
useCatalogueImage: () => ({
|
||||
variants: null,
|
||||
placeholderStyle: undefined,
|
||||
markLoaded: () => {},
|
||||
markFailed: () => false,
|
||||
}),
|
||||
useViewportCatalogueImageUrl: () => null,
|
||||
},
|
||||
"./library-category": {
|
||||
getLibraryPresentationImportSources: () => ({
|
||||
|
|
@ -53,6 +60,10 @@ it("refreshes grouped progress on member changes and preserves known offline tot
|
|||
isGameReadyToPlay: () => false,
|
||||
resolveClassicsBadge: () => ({}),
|
||||
},
|
||||
"@renderer/helpers/catalogue-images": {
|
||||
getCatalogueImageDensitySrcSet: () => "",
|
||||
pickCatalogueImageUrl: () => "",
|
||||
},
|
||||
"@renderer/components": {
|
||||
SteamLibraryBadge: () => null,
|
||||
AchievementProgress: (props: Record<string, unknown>) =>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import {
|
|||
useCoverPoster,
|
||||
useGameCard,
|
||||
useAnimatedSourceWarmup,
|
||||
useCatalogueImage,
|
||||
useViewportCatalogueImageUrl,
|
||||
} from "@renderer/hooks";
|
||||
import {
|
||||
getCatalogueImageDensitySrcSet,
|
||||
pickCatalogueImageUrl,
|
||||
} from "@renderer/helpers/catalogue-images";
|
||||
import {
|
||||
CLASSICS_PS_PLATFORM_LABELS,
|
||||
isGameReadyToPlay,
|
||||
|
|
@ -34,6 +40,7 @@ import "./library-game-card-large.scss";
|
|||
|
||||
// Cards scrolled past or remounted within this window never load, keeping scroll bursts off the API.
|
||||
const ACHIEVEMENT_PROGRESS_SETTLE_MS = 250;
|
||||
const LOGO_DISPLAY_WIDTH = 400;
|
||||
|
||||
interface LibraryGameCardLargeProps {
|
||||
game: LibraryGame;
|
||||
|
|
@ -299,26 +306,34 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
|||
achievementGameKey,
|
||||
]);
|
||||
|
||||
const rawHeroSource = heroSources[heroIndex];
|
||||
const heroImage = useCatalogueImage(rawHeroSource, game.images);
|
||||
const heroVariantUrl = useViewportCatalogueImageUrl(heroImage.variants);
|
||||
const { markLoaded: markHeroLoaded, markFailed: markHeroFailed } = heroImage;
|
||||
|
||||
useEffect(() => {
|
||||
const currentUrl = heroSources[heroIndex];
|
||||
const currentUrl = heroVariantUrl ?? heroSources[heroIndex];
|
||||
if (!currentUrl) return;
|
||||
|
||||
const img = new Image();
|
||||
img.src = normalizePathForCss(currentUrl);
|
||||
|
||||
img.onload = markHeroLoaded;
|
||||
img.onerror = () => {
|
||||
if (markHeroFailed()) return;
|
||||
if (heroIndex < heroSources.length - 1) {
|
||||
setHeroIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [heroIndex, heroSources]);
|
||||
}, [heroIndex, heroSources, heroVariantUrl, markHeroLoaded, markHeroFailed]);
|
||||
|
||||
const rawHeroSource = heroSources[heroIndex];
|
||||
const isAnimatedHero = isAnimatedCoverCandidate(rawHeroSource);
|
||||
const isAnimatedHero =
|
||||
!heroImage.variants && isAnimatedCoverCandidate(rawHeroSource);
|
||||
const heroPoster = useCoverPoster(rawHeroSource, isAnimatedHero);
|
||||
const shouldHoldHeroFrame =
|
||||
isAnimatedHero && !isCoverHovered && !autoplayAnimatedArtwork;
|
||||
|
|
@ -329,7 +344,8 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
|||
isAnimatedHero && !autoplayAnimatedArtwork && Boolean(heroPoster)
|
||||
);
|
||||
|
||||
let displayHeroSource: string | null | undefined = rawHeroSource;
|
||||
let displayHeroSource: string | null | undefined =
|
||||
heroVariantUrl ?? rawHeroSource;
|
||||
if (isAwaitingHeroPoster) {
|
||||
displayHeroSource = undefined;
|
||||
} else if (shouldHoldHeroFrame && heroPoster) {
|
||||
|
|
@ -346,14 +362,18 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
|||
? normalizePathForCss(displayHeroSource)
|
||||
: "";
|
||||
|
||||
const heroPlaceholder = heroImage.isLoaded ? null : heroImage.placeholder;
|
||||
const backgroundStyle = useMemo(
|
||||
() =>
|
||||
!usesAnimatedHeroLayer && displayHeroSource
|
||||
? {
|
||||
backgroundImage: `url("${normalizePathForCss(displayHeroSource)}")`,
|
||||
backgroundImage: [displayHeroSource, heroPlaceholder]
|
||||
.filter(Boolean)
|
||||
.map((url) => `url("${normalizePathForCss(url)}")`)
|
||||
.join(", "),
|
||||
}
|
||||
: {},
|
||||
[usesAnimatedHeroLayer, displayHeroSource]
|
||||
[usesAnimatedHeroLayer, displayHeroSource, heroPlaceholder]
|
||||
);
|
||||
|
||||
const classicsForegroundUrl = useMemo(() => {
|
||||
|
|
@ -363,6 +383,8 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
|||
}, [renderClassicsBlurred, activeHeroCandidate, displayHeroSource]);
|
||||
|
||||
const logoImage = game.customLogoImageUrl ?? game.logoImageUrl;
|
||||
const logoCatalogueImage = useCatalogueImage(logoImage, game.images);
|
||||
const logoVariants = logoCatalogueImage.variants;
|
||||
|
||||
const { label: classicsPlatformLabel, icon: classicsEmulatorIcon } =
|
||||
resolveClassicsBadge(
|
||||
|
|
@ -491,9 +513,22 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
|||
<div className="library-game-card-large__logo-container">
|
||||
{logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
src={
|
||||
logoVariants
|
||||
? pickCatalogueImageUrl(logoVariants, LOGO_DISPLAY_WIDTH)
|
||||
: logoImage
|
||||
}
|
||||
srcSet={
|
||||
logoVariants
|
||||
? getCatalogueImageDensitySrcSet(
|
||||
logoVariants,
|
||||
LOGO_DISPLAY_WIDTH
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
alt={game.title}
|
||||
className="library-game-card-large__logo"
|
||||
onError={logoCatalogueImage.markFailed}
|
||||
/>
|
||||
) : (
|
||||
<h3 className="library-game-card-large__title">{game.title}</h3>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ import {
|
|||
isAnimatedCoverCandidate,
|
||||
useAppSelector,
|
||||
useAnimatedSourceWarmup,
|
||||
useCatalogueImage,
|
||||
} from "@renderer/hooks";
|
||||
import {
|
||||
getCatalogueImageSrcSet,
|
||||
pickCatalogueImageUrl,
|
||||
} from "@renderer/helpers/catalogue-images";
|
||||
import {
|
||||
CLASSICS_PS_PLATFORM_LABELS,
|
||||
isGameReadyToPlay,
|
||||
|
|
@ -31,6 +36,8 @@ import {
|
|||
import "./library-game-card.scss";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
const COVER_DISPLAY_WIDTH = 300;
|
||||
|
||||
interface LibraryGameCardProps {
|
||||
game: LibraryGame;
|
||||
onContextMenu: (
|
||||
|
|
@ -117,7 +124,10 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
|||
const isChosenCoverActive = Boolean(candidates[fallbackIndex]?.isChosenCover);
|
||||
|
||||
const rawActiveSource = sources[fallbackIndex];
|
||||
const isAnimatedCover = isAnimatedCoverCandidate(rawActiveSource);
|
||||
const catalogueImage = useCatalogueImage(rawActiveSource, game.images);
|
||||
const coverVariants = catalogueImage.variants;
|
||||
const isAnimatedCover =
|
||||
!coverVariants && isAnimatedCoverCandidate(rawActiveSource);
|
||||
const coverPoster = useCoverPoster(rawActiveSource, isAnimatedCover);
|
||||
const [isCoverHovered, setIsCoverHovered] = useState(false);
|
||||
const shouldHoldFrame =
|
||||
|
|
@ -149,6 +159,8 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
|||
const showReadyBadge = !hideReadySizeBadges && isInstalled;
|
||||
|
||||
const handleImageError = () => {
|
||||
if (catalogueImage.markFailed()) return;
|
||||
|
||||
logger.warn(`Image failed to load for ${game.title}`, {
|
||||
failedUrl: sources[fallbackIndex],
|
||||
level: fallbackIndex,
|
||||
|
|
@ -209,12 +221,23 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
|||
|
||||
return (
|
||||
<img
|
||||
src={displayImageSource}
|
||||
src={
|
||||
coverVariants
|
||||
? pickCatalogueImageUrl(coverVariants, COVER_DISPLAY_WIDTH)
|
||||
: displayImageSource
|
||||
}
|
||||
srcSet={
|
||||
coverVariants ? getCatalogueImageSrcSet(coverVariants) : undefined
|
||||
}
|
||||
sizes={coverVariants ? `auto, ${COVER_DISPLAY_WIDTH}px` : undefined}
|
||||
style={catalogueImage.placeholderStyle}
|
||||
alt={game.title}
|
||||
className={`library-game-card__game-image ${
|
||||
isChosenCoverActive ? "library-game-card__game-image--contain" : ""
|
||||
}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={catalogueImage.markLoaded}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ export const UserLibraryGameCard = memo(function UserLibraryGameCard({
|
|||
<VerticalCoverCard
|
||||
gameTitle={game.title}
|
||||
coverImageUrls={[coverImageUrl]}
|
||||
catalogueImages={game.images}
|
||||
useClassicsLayout={
|
||||
game.shop === "launchbox" && !game.customLibraryImageUrl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ const projectGroup = (
|
|||
logoPosition: presentationGame.logoPosition,
|
||||
downloadSources: presentationGame.downloadSources,
|
||||
selectedArtworkTypes: presentationGame.selectedArtworkTypes,
|
||||
images: presentationGame.images,
|
||||
achievementCount: operationalGame.achievementCount,
|
||||
unlockedAchievementCount: operationalGame.unlockedAchievementCount,
|
||||
customIconUrl: custom("customIconUrl"),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@ export interface ProtonVersion {
|
|||
source?: "steam" | "compatibility_tools" | "unknown";
|
||||
}
|
||||
|
||||
export interface CatalogueImageVariants {
|
||||
base: string;
|
||||
widths: number[];
|
||||
thumbHash: string | null;
|
||||
}
|
||||
|
||||
/** Resized CDN copies keyed by the original image URL. */
|
||||
export type CatalogueImages = Record<string, CatalogueImageVariants>;
|
||||
|
||||
export interface ShopAssets {
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
|
|
@ -77,6 +86,7 @@ export interface ShopAssets {
|
|||
epicArtworkUpdatedAtByType?: Partial<
|
||||
Record<"icon" | "portrait" | "landscape", number>
|
||||
>;
|
||||
images?: CatalogueImages;
|
||||
}
|
||||
|
||||
export type ShopDetails = SteamAppDetails & {
|
||||
|
|
|
|||
|
|
@ -8913,6 +8913,11 @@ throttle-debounce@^2.1.0:
|
|||
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
|
||||
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
|
||||
|
||||
thumbhash@0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/thumbhash/-/thumbhash-0.1.1.tgz#bd2b8616fc043f2b17151dfce0cce1408e0ebbeb"
|
||||
integrity sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==
|
||||
|
||||
tiny-async-pool@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz#c013e1b369095e7005db5595f95e646cca6ef8a5"
|
||||
|
|
|
|||
Loading…
Reference in a new issue