Merge branch 'main' of github.com:hydralauncher/hydra into feat/HYD-781

This commit is contained in:
Chubby Granny Chaser
2025-05-10 17:20:57 +01:00
30 changed files with 265 additions and 271 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { CatalogueCategory } from "@shared"; import { CatalogueCategory } from "@shared";
import { ShopAssets } from "@types";
const getCatalogue = async ( const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -11,7 +12,7 @@ const getCatalogue = async (
skip: "0", skip: "0",
}); });
return HydraApi.get( return HydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`, `/catalogue/${category}?${params.toString()}`,
{}, {},
{ needsAuth: false } { needsAuth: false }

View File

@@ -1,10 +1,13 @@
import { getSteamAppDetails, logger } from "@main/services"; import { getSteamAppDetails, logger } from "@main/services";
import type { ShopDetails, GameShop } from "@types"; import type { ShopDetails, GameShop, ShopDetailsWithAssets } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers"; import {
import { gamesShopCacheSublevel, levelKeys } from "@main/level"; gamesShopAssetsSublevel,
gamesShopCacheSublevel,
levelKeys,
} from "@main/level";
const getLocalizedSteamAppDetails = async ( const getLocalizedSteamAppDetails = async (
objectId: string, objectId: string,
@@ -14,22 +17,7 @@ const getLocalizedSteamAppDetails = async (
return getSteamAppDetails(objectId, language); return getSteamAppDetails(objectId, language);
} }
return getSteamAppDetails(objectId, language).then( return getSteamAppDetails(objectId, language);
async (localizedAppDetails) => {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
if (steamGame && localizedAppDetails) {
return {
...localizedAppDetails,
name: steamGame.name,
};
}
return null;
}
);
}; };
const getGameShopDetails = async ( const getGameShopDetails = async (
@@ -37,34 +25,44 @@ const getGameShopDetails = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
language: string language: string
): Promise<ShopDetails | null> => { ): Promise<ShopDetailsWithAssets | null> => {
if (shop === "steam") { if (shop === "steam") {
const cachedData = await gamesShopCacheSublevel.get( const [cachedData, cachedAssets] = await Promise.all([
levelKeys.gameShopCacheItem(shop, objectId, language) gamesShopCacheSublevel.get(
); levelKeys.gameShopCacheItem(shop, objectId, language)
),
gamesShopAssetsSublevel.get(levelKeys.game(shop, objectId)),
]);
const appDetails = getLocalizedSteamAppDetails(objectId, language).then( const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => { (result) => {
if (result) { if (result) {
result.name = cachedAssets?.title ?? result.name;
gamesShopCacheSublevel gamesShopCacheSublevel
.put(levelKeys.gameShopCacheItem(shop, objectId, language), result) .put(levelKeys.gameShopCacheItem(shop, objectId, language), result)
.catch((err) => { .catch((err) => {
logger.error("Could not cache game details", err); logger.error("Could not cache game details", err);
}); });
return {
...result,
assets: cachedAssets ?? null,
};
} }
return result; return null;
} }
); );
if (cachedData) { if (cachedData) {
return { return {
...cachedData, ...cachedData,
objectId, assets: cachedAssets ?? null,
} as ShopDetails; };
} }
return Promise.resolve(appDetails); return appDetails;
} }
throw new Error("Not implemented"); throw new Error("Not implemented");

View File

@@ -11,12 +11,12 @@ const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
.then((language) => language || "en"); .then((language) => language || "en");
const trendingGames = await HydraApi.get<TrendingGame[]>( const trendingGames = await HydraApi.get<TrendingGame[]>(
"/games/trending", "/games/featured",
{ language }, { language },
{ needsAuth: false } { needsAuth: false }
).catch(() => []); ).catch(() => []);
return trendingGames; return trendingGames.slice(0, 1);
}; };
registerEvent("getTrendingGames", getTrendingGames); registerEvent("getTrendingGames", getTrendingGames);

View File

@@ -0,0 +1,14 @@
import type { GameShop, ShopAssets } from "@types";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
const saveGameShopAssets = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
assets: ShopAssets
): Promise<void> => {
return gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), assets);
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -3,6 +3,7 @@ import { ipcMain } from "electron";
import "./catalogue/get-catalogue"; import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details"; import "./catalogue/get-game-shop-details";
import "./catalogue/save-game-shop-assets";
import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game"; import "./catalogue/get-random-game";
import "./catalogue/search-games"; import "./catalogue/search-games";

View File

@@ -1,12 +1,13 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements"; import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
const addGameToLibrary = async ( const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -17,6 +18,8 @@ const addGameToLibrary = async (
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
let game = await gamesSublevel.get(gameKey); let game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
if (game) { if (game) {
await downloadsSublevel.del(gameKey); await downloadsSublevel.del(gameKey);
@@ -24,17 +27,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(gameKey, game); await gamesSublevel.put(gameKey, game);
} else { } else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
game = { game = {
title, title,
iconUrl, iconUrl: gameAssets?.iconUrl ?? null,
objectId, objectId,
shop, shop,
remoteId: null, remoteId: null,

View File

@@ -1,6 +1,10 @@
import type { LibraryGame } from "@types"; import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadsSublevel, gamesSublevel } from "@main/level"; import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => { const getLibrary = async (): Promise<LibraryGame[]> => {
return gamesSublevel return gamesSublevel
@@ -12,11 +16,13 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
.filter(([_key, game]) => game.isDeleted === false) .filter(([_key, game]) => game.isDeleted === false)
.map(async ([key, game]) => { .map(async ([key, game]) => {
const download = await downloadsSublevel.get(key); const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
return { return {
id: key, id: key,
...game, ...game,
download: download ?? null, download: download ?? null,
...gameAssets,
}; };
}) })
); );

View File

@@ -1,11 +1,14 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types"; import type { Download, StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi, logger } from "@main/services"; import { DownloadManager, HydraApi, logger } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { Downloader, DownloadError, steamUrlBuilder } from "@shared"; import { Downloader, DownloadError } from "@shared";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
const startGameDownload = async ( const startGameDownload = async (
@@ -36,6 +39,7 @@ const startGameDownload = async (
} }
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */ /* Delete any previous download */
await downloadsSublevel.del(gameKey); await downloadsSublevel.del(gameKey);
@@ -46,17 +50,9 @@ const startGameDownload = async (
isDeleted: false, isDeleted: false,
}); });
} else { } else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gamesSublevel.put(gameKey, { await gamesSublevel.put(gameKey, {
title, title,
iconUrl, iconUrl: gameAssets?.iconUrl ?? null,
objectId, objectId,
shop, shop,
remoteId: null, remoteId: null,

View File

@@ -1,97 +1,12 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi, logger } from "@main/services"; import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import type { UserProfile } from "@types"; import type { UserProfile } from "@types";
import { steamUrlBuilder } from "@shared";
const getSteamGame = async (objectId: string) => {
try {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
return {
title: steamGame.name as string,
iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon),
};
} catch (err) {
logger.error("Failed to get Steam game", err);
return null;
}
};
const getUser = async ( const getUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { return HydraApi.get<UserProfile>(`/users/${userId}`).catch(() => null);
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
if (!profile) return null;
const recentGames = await Promise.all(
profile.recentGames
.map(async (game) => {
const steamGame = await getSteamGame(game.objectId);
return {
...game,
...steamGame,
};
})
.filter((game) => game)
);
const libraryGames = await Promise.all(
profile.libraryGames
.map(async (game) => {
const steamGame = await getSteamGame(game.objectId);
return {
...game,
...steamGame,
};
})
.filter((game) => game)
);
if (profile.currentGame) {
const steamGame = await getSteamGame(profile.currentGame.objectId);
if (steamGame) {
profile.currentGame = {
...profile.currentGame,
...steamGame,
};
}
}
const friends = await Promise.all(
profile.friends.map(async (friend) => {
if (!friend.currentGame) return friend;
const currentGame = await getSteamGame(friend.currentGame.objectId);
return {
...friend,
currentGame: {
...friend.currentGame,
...currentGame,
},
};
})
);
return {
...profile,
friends,
libraryGames,
recentGames,
};
} catch (err) {
return null;
}
}; };
registerEvent("getUser", getUser); registerEvent("getUser", getUser);

View File

@@ -0,0 +1,11 @@
import type { ShopAssets } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
levelKeys.gameShopAssets,
{
valueEncoding: "json",
}
);

View File

@@ -1,5 +1,6 @@
export * from "./downloads"; export * from "./downloads";
export * from "./games"; export * from "./games";
export * from "./game-shop-assets";
export * from "./game-shop-cache"; export * from "./game-shop-cache";
export * from "./game-achievements"; export * from "./game-achievements";
export * from "./keys"; export * from "./keys";

View File

@@ -6,6 +6,7 @@ export const levelKeys = {
user: "user", user: "user",
auth: "auth", auth: "auth",
themes: "themes", themes: "themes",
gameShopAssets: "gameShopAssets",
gameShopCache: "gameShopCache", gameShopCache: "gameShopCache",
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
`${shop}:${objectId}:${language}`, `${shop}:${objectId}:${language}`,

View File

@@ -1,10 +1,15 @@
import { ShopAssets } from "@types";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers"; import { gamesShopAssetsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { steamUrlBuilder } from "@shared";
import { gamesSublevel, levelKeys } from "@main/level"; type ProfileGame = {
id: string;
lastTimePlayed: Date | null;
playTimeInMilliseconds: number;
} & ShopAssets;
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games") return HydraApi.get<ProfileGame[]>("/profile/games")
.then(async (response) => { .then(async (response) => {
for (const game of response) { for (const game of response) {
const localGame = await gamesSublevel.get( const localGame = await gamesSublevel.get(
@@ -31,25 +36,32 @@ export const mergeWithRemoteGames = async () => {
playTimeInMilliseconds: updatedPlayTime, playTimeInMilliseconds: updatedPlayTime,
}); });
} else { } else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId, objectId: game.objectId,
title: steamGame?.name, title: game.title,
remoteId: game.id, remoteId: game.id,
shop: game.shop, shop: game.shop,
iconUrl, iconUrl: game.iconUrl,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds, playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false, isDeleted: false,
}); });
} }
await gamesShopAssetsSublevel.put(
levelKeys.game(game.shop, game.objectId),
{
shop: game.shop,
objectId: game.objectId,
title: game.title,
coverImageUrl: game.coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
}
);
} }
}) })
.catch(() => {}); .catch(() => {});

View File

@@ -1,14 +0,0 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import Piscina from "piscina";
import { seedsPath } from "@main/constants";
export const steamGamesWorker = new Piscina({
filename: steamGamesWorkerPath,
workerData: {
steamGamesPath: path.join(seedsPath, "steam-games.json"),
},
maxThreads: 1,
});

View File

@@ -1,17 +0,0 @@
import type { SteamGame } from "@types";
import { slice } from "lodash-es";
import fs from "node:fs";
import { workerData } from "node:worker_threads";
const { steamGamesPath } = workerData;
const data = fs.readFileSync(steamGamesPath, "utf-8");
const steamGames = JSON.parse(data) as SteamGame[];
export const getById = (id: number) =>
steamGames.find((game) => game.id === id);
export const list = ({ limit, offset }: { limit: number; offset: number }) =>
slice(steamGames, offset, offset + limit);

View File

@@ -17,6 +17,7 @@ import type {
Theme, Theme,
FriendRequestSync, FriendRequestSync,
ShortcutLocation, ShortcutLocation,
ShopAssets,
} from "@types"; } from "@types";
import type { AuthPage, CatalogueCategory } from "@shared"; import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -64,6 +65,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("searchGames", payload, take, skip), ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) => getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category), ipcRenderer.invoke("getCatalogue", category),
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) => getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language), ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"), getRandomGame: () => ipcRenderer.invoke("getRandomGame"),

View File

@@ -9,7 +9,6 @@ import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { useCallback, useState, useMemo } from "react"; import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks"; import { useFormat, useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
export interface GameCardProps export interface GameCardProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
@@ -63,7 +62,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
> >
<div className="game-card__backdrop"> <div className="game-card__backdrop">
<img <img
src={steamUrlBuilder.library(game.objectId)} src={game.libraryImageUrl}
alt={game.title} alt={game.title}
className="game-card__cover" className="game-card__cover"
loading="lazy" loading="lazy"

View File

@@ -33,29 +33,27 @@ export function Hero() {
} }
if (featuredGameDetails?.length) { if (featuredGameDetails?.length) {
return featuredGameDetails.map((game, index) => ( return featuredGameDetails.map((game) => (
<button <button
type="button" type="button"
onClick={() => navigate(game.uri)} onClick={() => navigate(game.uri)}
className="hero" className="hero"
key={index} key={game.uri}
> >
<div className="hero__backdrop"> <div className="hero__backdrop">
<img <img
src={game.background} src={game.libraryHeroImageUrl}
alt={game.description} alt={game.description ?? ""}
className="hero__media" className="hero__media"
/> />
<div className="hero__content"> <div className="hero__content">
{game.logo && ( <img
<img src={game.logoImageUrl}
src={game.logo} width="250px"
width="250px" alt={game.description ?? ""}
alt={game.description} loading="eager"
loading="eager" />
/>
)}
<p className="hero__description">{game.description}</p> <p className="hero__description">{game.description}</p>
</div> </div>
</div> </div>

View File

@@ -21,7 +21,7 @@ import type {
GameShop, GameShop,
GameStats, GameStats,
LibraryGame, LibraryGame,
ShopDetails, ShopDetailsWithAssets,
UserAchievement, UserAchievement,
} from "@types"; } from "@types";
@@ -69,7 +69,9 @@ export function GameDetailsContextProvider({
gameTitle, gameTitle,
shop, shop,
}: Readonly<GameDetailsContextProps>) { }: Readonly<GameDetailsContextProps>) {
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null); const [shopDetails, setShopDetails] = useState<ShopDetailsWithAssets | null>(
null
);
const [achievements, setAchievements] = useState<UserAchievement[] | null>( const [achievements, setAchievements] = useState<UserAchievement[] | null>(
null null
); );
@@ -79,7 +81,7 @@ export function GameDetailsContextProvider({
const [stats, setStats] = useState<GameStats | null>(null); const [stats, setStats] = useState<GameStats | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true);
const [gameColor, setGameColor] = useState(""); const [gameColor, setGameColor] = useState("");
const [isGameRunning, setIsGameRunning] = useState(false); const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false);
@@ -120,7 +122,7 @@ export function GameDetailsContextProvider({
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
window.electron const shopDetailsPromise = window.electron
.getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language)) .getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language))
.then((result) => { .then((result) => {
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -135,15 +137,42 @@ export function GameDetailsContextProvider({
) { ) {
setHasNSFWContentBlocked(true); setHasNSFWContentBlocked(true);
} }
})
.finally(() => { if (result?.assets) {
setIsLoading(false); setIsLoading(false);
}
}); });
window.electron.getGameStats(objectId, shop).then((result) => { const statsPromise = window.electron
if (abortController.signal.aborted) return; .getGameStats(objectId, shop)
setStats(result); .then((result) => {
}); if (abortController.signal.aborted) return null;
setStats(result);
return result;
});
Promise.all([shopDetailsPromise, statsPromise])
.then(([_, stats]) => {
if (stats) {
const assets = stats.assets;
if (assets) {
window.electron.saveGameShopAssets(objectId, shop, assets);
setShopDetails((prev) => {
if (!prev) return null;
console.log("assets", assets);
return {
...prev,
assets,
};
});
}
}
})
.finally(() => {
if (abortController.signal.aborted) return;
setIsLoading(false);
});
if (userDetails) { if (userDetails) {
window.electron window.electron

View File

@@ -3,13 +3,13 @@ import type {
GameShop, GameShop,
GameStats, GameStats,
LibraryGame, LibraryGame,
ShopDetails, ShopDetailsWithAssets,
UserAchievement, UserAchievement,
} from "@types"; } from "@types";
export interface GameDetailsContext { export interface GameDetailsContext {
game: LibraryGame | null; game: LibraryGame | null;
shopDetails: ShopDetails | null; shopDetails: ShopDetailsWithAssets | null;
repacks: GameRepack[]; repacks: GameRepack[];
shop: GameShop; shop: GameShop;
gameTitle: string; gameTitle: string;

View File

@@ -3,7 +3,6 @@ import type {
AppUpdaterEvent, AppUpdaterEvent,
GameShop, GameShop,
HowLongToBeatCategory, HowLongToBeatCategory,
ShopDetails,
Steam250Game, Steam250Game,
DownloadProgress, DownloadProgress,
SeedingStatus, SeedingStatus,
@@ -33,6 +32,9 @@ import type {
Badge, Badge,
Auth, Auth,
ShortcutLocation, ShortcutLocation,
CatalogueSearchResult,
ShopAssets,
ShopDetailsWithAssets,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage"; import type disk from "diskusage";
@@ -69,13 +71,18 @@ declare global {
payload: CatalogueSearchPayload, payload: CatalogueSearchPayload,
take: number, take: number,
skip: number skip: number
) => Promise<{ edges: any[]; count: number }>; ) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<any[]>; getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
saveGameShopAssets: (
objectId: string,
shop: GameShop,
assets: ShopAssets
) => Promise<void>;
getGameShopDetails: ( getGameShopDetails: (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
language: string language: string
) => Promise<ShopDetails | null>; ) => Promise<ShopDetailsWithAssets | null>;
getRandomGame: () => Promise<Steam250Game>; getRandomGame: () => Promise<Steam250Game>;
getHowLongToBeat: ( getHowLongToBeat: (
objectId: string, objectId: string,

View File

@@ -1,6 +1,5 @@
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useUserDetails } from "@renderer/hooks"; import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -120,8 +119,15 @@ export function AchievementsContent({
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false); const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } = const {
useContext(gameDetailsContext); gameTitle,
objectId,
shop,
shopDetails,
achievements,
gameColor,
setGameColor,
} = useContext(gameDetailsContext);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -131,10 +137,13 @@ export function AchievementsContent({
}, [dispatch, gameTitle]); }, [dispatch, gameTitle]);
const handleHeroLoad = async () => { const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectId!), { const output = await average(
amount: 1, shopDetails?.assets?.libraryHeroImageUrl ?? "",
format: "hex", {
}); amount: 1,
format: "hex",
}
);
const backgroundColor = output const backgroundColor = output
? (new Color(output).darken(0.7).toString() as string) ? (new Color(output).darken(0.7).toString() as string)
@@ -179,7 +188,7 @@ export function AchievementsContent({
return ( return (
<div className="achievements-content__achievements-list"> <div className="achievements-content__achievements-list">
<img <img
src={steamUrlBuilder.libraryHero(objectId)} src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="achievements-content__achievements-list__image" className="achievements-content__achievements-list__image"
alt={gameTitle} alt={gameTitle}
onLoad={handleHeroLoad} onLoad={handleHeroLoad}
@@ -205,7 +214,7 @@ export function AchievementsContent({
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })} to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
> >
<img <img
src={steamUrlBuilder.logo(objectId)} src={shopDetails?.assets?.logoImageUrl ?? ""}
className="achievements-content__achievements-list__section__container__hero__content__game-logo" className="achievements-content__achievements-list__section__container__hero__content__game-logo"
alt={gameTitle} alt={gameTitle}
/> />

View File

@@ -1,4 +1,4 @@
import type { DownloadSource } from "@types"; import type { CatalogueSearchResult, DownloadSource } from "@types";
import { import {
useAppDispatch, useAppDispatch,
@@ -44,7 +44,7 @@ export default function Catalogue() {
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]); const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<any[]>([]); const [results, setResults] = useState<CatalogueSearchResult[]>([]);
const [itemsCount, setItemsCount] = useState(0); const [itemsCount, setItemsCount] = useState(0);

View File

@@ -1,15 +1,15 @@
import { Badge } from "@renderer/components"; import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks } from "@renderer/hooks"; import { useAppSelector, useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./game-item.scss"; import "./game-item.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CatalogueSearchResult } from "@types";
export interface GameItemProps { export interface GameItemProps {
game: any; game: CatalogueSearchResult;
} }
export function GameItem({ game }: GameItemProps) { export function GameItem({ game }: GameItemProps) {
@@ -51,7 +51,7 @@ export function GameItem({ game }: GameItemProps) {
> >
<img <img
className="game-item__cover" className="game-item__cover"
src={steamUrlBuilder.library(game.objectId)} src={game.libraryImageUrl}
alt={game.title} alt={game.title}
loading="lazy" loading="lazy"
/> />

View File

@@ -9,7 +9,7 @@ import {
formatDownloadProgress, formatDownloadProgress,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { Downloader, formatBytes, steamUrlBuilder } from "@shared"; import { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
@@ -319,7 +319,7 @@ export function DownloadGroup({
<div className="download-group__cover"> <div className="download-group__cover">
<div className="download-group__cover-backdrop"> <div className="download-group__cover-backdrop">
<img <img
src={steamUrlBuilder.library(game.objectId)} src={game.libraryImageUrl ?? ""}
className="download-group__cover-image" className="download-group__cover-image"
alt={game.title} alt={game.title}
/> />

View File

@@ -9,7 +9,7 @@ import { Sidebar } from "./sidebar/sidebar";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage, steamUrlBuilder } from "@shared"; import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
@@ -59,10 +59,13 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1); const [backdropOpacity, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => { const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectId!), { const output = await average(
amount: 1, shopDetails?.assets?.libraryHeroImageUrl ?? "",
format: "hex", {
}); amount: 1,
format: "hex",
}
);
const backgroundColor = output const backgroundColor = output
? new Color(output).darken(0.7).toString() ? new Color(output).darken(0.7).toString()
@@ -100,7 +103,7 @@ export function GameDetailsContent() {
<section className="game-details__container"> <section className="game-details__container">
<div ref={heroRef} className="game-details__hero"> <div ref={heroRef} className="game-details__hero">
<img <img
src={steamUrlBuilder.libraryHero(objectId!)} src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="game-details__hero-image" className="game-details__hero-image"
alt={game?.title} alt={game?.title}
onLoad={handleHeroLoad} onLoad={handleHeroLoad}
@@ -119,7 +122,7 @@ export function GameDetailsContent() {
> >
<div className="game-details__hero-content"> <div className="game-details__hero-content">
<img <img
src={steamUrlBuilder.logo(objectId!)} src={shopDetails?.assets?.logoImageUrl ?? ""}
className="game-details__game-logo" className="game-details__game-logo"
alt={game?.title} alt={game?.title}
/> />

View File

@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components"; import { Button, GameCard, Hero } from "@renderer/components";
import type { Steam250Game } from "@types"; import type { ShopAssets, Steam250Game } from "@types";
import flameIconStatic from "@renderer/assets/icons/flame-static.png"; import flameIconStatic from "@renderer/assets/icons/flame-static.png";
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif"; import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
@@ -27,7 +27,9 @@ export default function Home() {
CatalogueCategory.Hot CatalogueCategory.Hot
); );
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({ const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, ShopAssets[]>
>({
[CatalogueCategory.Hot]: [], [CatalogueCategory.Hot]: [],
[CatalogueCategory.Weekly]: [], [CatalogueCategory.Weekly]: [],
[CatalogueCategory.Achievements]: [], [CatalogueCategory.Achievements]: [],

View File

@@ -12,7 +12,6 @@ import { userProfileContext } from "@renderer/context";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react"; import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { steamUrlBuilder } from "@shared";
import "./user-library-game-card.scss"; import "./user-library-game-card.scss";
interface UserLibraryGameCardProps { interface UserLibraryGameCardProps {
@@ -150,7 +149,7 @@ export function UserLibraryGameCard({
</div> </div>
<img <img
src={steamUrlBuilder.cover(game.objectId)} src={game.coverImageUrl}
alt={game.title} alt={game.title}
className="user-library-game__game-image" className="user-library-game__game-image"
/> />

View File

@@ -36,27 +36,41 @@ export interface DownloadSource {
updatedAt: Date; updatedAt: Date;
} }
export interface ShopAssets {
objectId: string;
shop: GameShop;
title: string;
iconUrl: string | null;
libraryHeroImageUrl: string;
libraryImageUrl: string;
logoImageUrl: string;
logoPosition: string | null;
coverImageUrl: string;
}
export type ShopDetails = SteamAppDetails & { export type ShopDetails = SteamAppDetails & {
objectId: string; objectId: string;
}; };
export type ShopDetailsWithAssets = ShopDetails & {
assets: ShopAssets | null;
};
export interface TorrentFile { export interface TorrentFile {
path: string; path: string;
length: number; length: number;
} }
export interface UserGame { export type UserGame = {
objectId: string; objectId: string;
shop: GameShop; shop: GameShop;
title: string; title: string;
iconUrl: string | null;
cover: string;
playTimeInSeconds: number; playTimeInSeconds: number;
lastTimePlayed: Date | null; lastTimePlayed: Date | null;
unlockedAchievementCount: number; unlockedAchievementCount: number;
achievementCount: number; achievementCount: number;
achievementsPointsEarnedSum: number; achievementsPointsEarnedSum: number;
} } & ShopAssets;
export interface GameRunning { export interface GameRunning {
id: string; id: string;
@@ -100,13 +114,11 @@ export interface UserFriend {
profileImageUrl: string | null; profileImageUrl: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
currentGame: { currentGame:
title: string; | (ShopAssets & {
iconUrl: string; sessionDurationInSeconds: number;
objectId: string; })
shop: GameShop; | null;
sessionDurationInSeconds: number;
} | null;
} }
export interface UserFriends { export interface UserFriends {
@@ -138,10 +150,10 @@ export interface UserRelation {
updatedAt: string; updatedAt: string;
} }
export interface UserProfileCurrentGame extends Omit<GameRunning, "objectId"> { export type UserProfileCurrentGame = GameRunning &
objectId: string; ShopAssets & {
sessionDurationInSeconds: number; sessionDurationInSeconds: number;
} };
export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS"; export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS";
@@ -215,13 +227,12 @@ export interface DownloadSourceValidationResult {
export interface GameStats { export interface GameStats {
downloadCount: number; downloadCount: number;
playerCount: number; playerCount: number;
assets: ShopAssets | null;
} }
export interface TrendingGame { export interface TrendingGame extends ShopAssets {
description: string | null;
uri: string; uri: string;
description: string;
background: string;
logo: string | null;
} }
export interface UserStatsPercentile { export interface UserStatsPercentile {
@@ -302,10 +313,25 @@ export interface CatalogueSearchPayload {
developers: string[]; developers: string[];
} }
export interface LibraryGame extends Game { export type CatalogueSearchResult = {
id: string; id: string;
download: Download | null; tags: string[];
} genres: string[];
objectId: string;
shop: GameShop;
createdAt: Date;
updatedAt: Date;
title: string;
installCount: number;
achievementCount: number;
shopData: string;
} & ShopAssets;
export type LibraryGame = Game &
Partial<ShopAssets> & {
id: string;
download: Download | null;
};
export * from "./game.types"; export * from "./game.types";
export * from "./steam.types"; export * from "./steam.types";