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:
Chubby 2026-10-29 18:36:47 +00:00 committed by GitHub
parent 57441a2885
commit 6ecb7ed6ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 380 additions and 12 deletions

View file

@ -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",

View file

@ -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 }
);

View file

@ -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}
/>
);

View 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);
});
});

View 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;
};

View file

@ -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";

View 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,
};
}

View file

@ -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": {

View file

@ -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>) =>

View file

@ -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>

View file

@ -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}
/>
);

View file

@ -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
}

View file

@ -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"),

View file

@ -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 & {

View file

@ -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"