Compare commits

..

16 Commits

Author SHA1 Message Date
Moyase
bd8bc4f955 Merge branch 'main' into feat/LBX-398 2026-01-21 21:09:41 +02:00
Moyase
63bb5ca511 Merge pull request #1948 from hydralauncher/fix/LBX-454
refactor: improve notification handling in SidebarProfile component
2026-01-21 21:05:46 +02:00
Moyase
073d3f25e3 Merge branch 'main' into fix/LBX-454 2026-01-21 18:24:44 +02:00
Moyase
066185e6ee Merge pull request #1947 from hydralauncher/feat/LBX-452
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: implement dynamic port discovery for Python RPC service
2026-01-21 18:24:25 +02:00
Moyase
8ce4b59294 Merge branch 'main' into feat/LBX-452 2026-01-21 18:09:16 +02:00
Moyase
90b62e4e8d Merge pull request #1946 from hydralauncher/feat/download-option-availability
feat: enhance repack availability status display with orb displaying availability
2026-01-21 18:09:03 +02:00
Moyasee
5ddfd88ef7 refactor: remove polling to notifications count api 2026-01-21 11:33:42 +02:00
Moyasee
569ad1c862 chore: add get-port dependency and refactor Python RPC port handling 2026-01-21 11:28:14 +02:00
Moyasee
50bafbb7f6 refactor: improve notification handling in SidebarProfile component 2026-01-20 19:41:24 +02:00
Moyasee
46154fa49a fix: correct error handling in Python RPC process exit code 2026-01-20 19:34:04 +02:00
Moyasee
aae35b591d feat: implement dynamic port discovery for Python RPC service 2026-01-20 19:25:32 +02:00
Moyasee
dc24c16789 Merge branch 'feat/LBX-398' of https://github.com/hydralauncher/hydra into feat/LBX-398 2026-01-17 02:08:14 +02:00
Moyasee
37863c0e80 fix: update executable path handling to include game root detection and logging 2026-01-17 02:07:25 +02:00
Moyase
98852ce31e Merge branch 'main' into feat/LBX-398 2026-01-15 23:18:34 +02:00
Moyasee
f9678ece1b refactor: remove game disk usage functionality and update related components 2026-01-15 01:51:36 +02:00
Moyasee
af6d027b06 feat: implement game disk usage tracking and enhance UI 2026-01-14 03:48:40 +02:00
18 changed files with 668 additions and 47 deletions

View File

@@ -64,6 +64,7 @@
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"get-port": "^7.1.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",

View File

@@ -199,7 +199,6 @@
"downloader_not_configured": "Available but not configured",
"downloader_offline": "Link is offline",
"downloader_not_available": "Not available",
"recommended": "Recommended",
"go_to_settings": "Go to Settings",
"select_executable": "Select",
"no_executable_selected": "No executable selected",
@@ -791,7 +790,10 @@
"manual_playtime_tooltip": "This playtime has been manually updated",
"all_games": "All Games",
"recently_played": "Recently Played",
"favorites": "Favorites"
"favorites": "Favorites",
"disk_usage": "Disk usage",
"disk_usage_tooltip": "Installed size on disk",
"installer_size_tooltip": "Installer size"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -0,0 +1,247 @@
import path from "node:path";
import fs from "node:fs";
const NESTED_EXECUTABLE_DIRS = new Set([
"bin",
"bin32",
"bin64",
"binaries",
"win32",
"win64",
"x64",
"x86",
"game",
"runtime",
"engine",
]);
const GAME_ROOT_INDICATORS = new Set([
"data",
"assets",
"content",
"paks",
"pak",
"resources",
"localization",
"languages",
"saves",
"mods",
"dlc",
"music",
"sound",
"sounds",
"audio",
"videos",
"movies",
"cinematics",
"textures",
"shaders",
"configs",
"config",
"settings",
"plugins",
"native",
"managed",
"mono",
"dotnet",
"engine",
"launcher",
]);
const UNITY_DATA_SUFFIX = "_data";
const GAME_DATA_EXTENSIONS = new Set([
".pak",
".dat",
".bundle",
".assets",
".forge",
".arc",
".pck",
".vpk",
".wad",
".bsa",
".ba2",
".big",
".cpk",
".fsb",
".bank",
]);
const MAX_UPWARD_LEVELS = 3;
const UNSAFE_ROOTS = new Set([
"program files",
"program files (x86)",
"users",
"windows",
"system32",
"appdata",
"programdata",
"steamapps",
"common",
"desktop",
"documents",
"downloads",
]);
interface DirectoryScore {
path: string;
score: number;
hasExecutable: boolean;
}
const isNestedExeDir = (dirName: string): boolean => {
return NESTED_EXECUTABLE_DIRS.has(dirName.toLowerCase());
};
const isUnsafePath = (dirPath: string): boolean => {
const normalized = dirPath.toLowerCase();
const parts = normalized.split(path.sep);
const lastPart = parts.at(-1) ?? "";
if (UNSAFE_ROOTS.has(lastPart)) {
return true;
}
const parsed = path.parse(dirPath);
return parsed.dir === parsed.root || dirPath === parsed.root;
};
const GAME_ROOT_FILES = new Set([
"steam_api.dll",
"steam_api64.dll",
"version.txt",
"readme.txt",
"eula.txt",
"unins000.exe",
"uninstall.exe",
]);
const scoreEntry = (
entry: fs.Dirent
): { score: number; hasExecutable: boolean } => {
const nameLower = entry.name.toLowerCase();
let score = 0;
let hasExecutable = false;
if (entry.isDirectory()) {
if (GAME_ROOT_INDICATORS.has(nameLower)) score += 2;
if (nameLower.endsWith(UNITY_DATA_SUFFIX)) score += 3;
if (nameLower === "binaries" || nameLower === "content") score += 2;
} else if (entry.isFile()) {
if (nameLower.endsWith(".exe")) {
hasExecutable = true;
score += 1;
}
if (GAME_DATA_EXTENSIONS.has(path.extname(nameLower))) score += 2;
if (GAME_ROOT_FILES.has(nameLower)) score += 1;
}
return { score, hasExecutable };
};
const scoreDirectory = async (dirPath: string): Promise<DirectoryScore> => {
try {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
let totalScore = 0;
let hasExecutable = false;
for (const entry of entries) {
const result = scoreEntry(entry);
totalScore += result.score;
hasExecutable = hasExecutable || result.hasExecutable;
}
return { path: dirPath, score: totalScore, hasExecutable };
} catch {
return { path: dirPath, score: 0, hasExecutable: false };
}
};
const collectCandidates = async (exeDir: string): Promise<DirectoryScore[]> => {
const candidates: DirectoryScore[] = [];
let currentDir = exeDir;
let levelsUp = 0;
while (levelsUp <= MAX_UPWARD_LEVELS) {
if (isUnsafePath(currentDir)) break;
const score = await scoreDirectory(currentDir);
candidates.push(score);
const dirName = path.basename(currentDir);
if (levelsUp === 0 && isNestedExeDir(dirName)) {
levelsUp++;
currentDir = path.dirname(currentDir);
continue;
}
if (score.score >= 3 && score.hasExecutable) break;
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
levelsUp++;
}
return candidates;
};
const selectBestCandidate = (candidates: DirectoryScore[]): DirectoryScore => {
let best = candidates[0];
for (const candidate of candidates) {
const isBetterWithExe =
candidate.score >= 3 &&
candidate.hasExecutable &&
(!best.hasExecutable || candidate.score > best.score);
const isBetterWithoutExe =
!best.hasExecutable && candidate.score > best.score;
if (isBetterWithExe || isBetterWithoutExe) {
best = candidate;
}
}
return best;
};
const getFallbackPath = (exeDir: string): string => {
const exeDirName = path.basename(exeDir);
if (isNestedExeDir(exeDirName)) {
const parentDir = path.dirname(exeDir);
if (!isUnsafePath(parentDir)) return parentDir;
}
return exeDir;
};
export const findGameRootFromExe = async (
exePath: string
): Promise<string | null> => {
try {
const exeDir = path.dirname(exePath);
if (isUnsafePath(exeDir)) return null;
const candidates = await collectCandidates(exeDir);
if (candidates.length === 0) return exeDir;
const bestCandidate = selectBestCandidate(candidates);
if (bestCandidate.score < 2) {
return getFallbackPath(exeDir);
}
return bestCandidate.path;
} catch {
return null;
}
};

View File

@@ -0,0 +1,39 @@
import path from "node:path";
import fs from "node:fs";
export const getDirectorySize = async (dirPath: string): Promise<number> => {
let totalSize = 0;
try {
const stat = await fs.promises.stat(dirPath);
if (stat.isFile()) {
return stat.size;
}
if (!stat.isDirectory()) {
return 0;
}
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
try {
if (entry.isDirectory()) {
totalSize += await getDirectorySize(fullPath);
} else if (entry.isFile()) {
const fileStat = await fs.promises.stat(fullPath);
totalSize += fileStat.size;
}
} catch {
// Skip files that can't be accessed
}
}
} catch {
// Path doesn't exist or can't be read
}
return totalSize;
};

View File

@@ -1,7 +1,9 @@
import path from "node:path";
import fs from "node:fs";
import { registerEvent } from "../register-event";
import { logger } from "@main/services";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const deleteArchive = async (
_event: Electron.IpcMainInvokeEvent,
@@ -11,8 +13,33 @@ const deleteArchive = async (
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
logger.info(`Deleted archive: ${filePath}`);
return true;
}
// Find the game that has this archive and clear installer size
const normalizedPath = path.normalize(filePath);
const downloads = await downloadsSublevel.values().all();
for (const download of downloads) {
if (!download.folderName) continue;
const downloadPath = path.normalize(
path.join(download.downloadPath, download.folderName)
);
if (downloadPath === normalizedPath) {
const gameKey = levelKeys.game(download.shop, download.objectId);
const game = await gamesSublevel.get(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
installerSizeInBytes: null,
});
}
break;
}
}
return true;
} catch (err) {
logger.error(`Failed to delete archive: ${filePath}`, err);

View File

@@ -5,15 +5,15 @@ import { getDownloadsPath } from "../helpers/get-downloads-path";
import { logger } from "@main/services";
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { downloadsSublevel, levelKeys } from "@main/level";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
): Promise<void> => {
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
const gameKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(gameKey);
if (!download?.folderName) return;
@@ -49,7 +49,16 @@ const deleteGameFolder = async (
await deleteFile(folderPath, true);
await deleteFile(metaPath);
await downloadsSublevel.del(downloadKey);
await downloadsSublevel.del(gameKey);
// Clear installer size from game record
const game = await gamesSublevel.get(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
installerSizeInBytes: null,
});
}
};
registerEvent("deleteGameFolder", deleteGameFolder);

View File

@@ -1,3 +1,6 @@
import path from "node:path";
import fs from "node:fs";
import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event";
import {
@@ -28,9 +31,40 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
achievements?.unlockedAchievements?.length ?? 0;
}
// Verify installer still exists, clear if deleted externally
let installerSizeInBytes = game.installerSizeInBytes;
if (installerSizeInBytes && download?.folderName) {
const installerPath = path.join(
download.downloadPath,
download.folderName
);
if (!fs.existsSync(installerPath)) {
installerSizeInBytes = null;
gamesSublevel.put(key, { ...game, installerSizeInBytes: null });
}
}
// Verify installed folder still exists, clear if deleted externally
let installedSizeInBytes = game.installedSizeInBytes;
if (installedSizeInBytes && game.executablePath) {
const executableDir = path.dirname(game.executablePath);
if (!fs.existsSync(executableDir)) {
installedSizeInBytes = null;
gamesSublevel.put(key, {
...game,
installerSizeInBytes,
installedSizeInBytes: null,
});
}
}
return {
id: key,
...game,
installerSizeInBytes,
installedSizeInBytes,
download: download ?? null,
unlockedAchievementCount,
achievementCount: game.achievementCount ?? 0,

View File

@@ -1,6 +1,9 @@
import { registerEvent } from "../register-event";
import { parseExecutablePath } from "../helpers/parse-executable-path";
import { getDirectorySize } from "../helpers/get-directory-size";
import { findGameRootFromExe } from "../helpers/find-game-root";
import { gamesSublevel, levelKeys } from "@main/level";
import { logger } from "@main/services";
import type { GameShop } from "@types";
const updateExecutablePath = async (
@@ -18,12 +21,40 @@ const updateExecutablePath = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
// Update immediately without size so UI responds fast
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
installedSizeInBytes: parsedPath ? game.installedSizeInBytes : null,
automaticCloudSync:
executablePath === null ? false : game.automaticCloudSync,
});
// Calculate size in background and update later
if (parsedPath) {
findGameRootFromExe(parsedPath)
.then(async (gameRoot) => {
if (!gameRoot) {
logger.warn(`Could not determine game root for: ${parsedPath}`);
return;
}
logger.log(`Game root detected: ${gameRoot} (exe: ${parsedPath})`);
const installedSizeInBytes = await getDirectorySize(gameRoot);
const currentGame = await gamesSublevel.get(gameKey);
if (!currentGame) return;
await gamesSublevel.put(gameKey, {
...currentGame,
installedSizeInBytes,
});
})
.catch((err) => {
logger.error(`Failed to calculate game size: ${err}`);
});
}
};
registerEvent("updateExecutablePath", updateExecutablePath);

View File

@@ -27,6 +27,7 @@ import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
import { JsHttpDownloader } from "./js-http-downloader";
import { getDirectorySize } from "@main/events/helpers/get-directory-size";
export class DownloadManager {
private static downloadingGameId: string | null = null;
@@ -360,6 +361,24 @@ export class DownloadManager {
userPreferences?.seedAfterDownloadComplete
);
// Calculate installer size in background
if (download.folderName) {
const installerPath = path.join(
download.downloadPath,
download.folderName
);
getDirectorySize(installerPath).then(async (installerSizeInBytes) => {
const currentGame = await gamesSublevel.get(gameId);
if (!currentGame) return;
await gamesSublevel.put(gameId, {
...currentGame,
installerSizeInBytes,
});
});
}
if (download.automaticallyExtract) {
this.handleExtraction(download, game);
} else {

View File

@@ -7,6 +7,7 @@ import { SevenZip, ExtractionProgress } from "./7zip";
import { WindowManager } from "./window-manager";
import { publishExtractionCompleteNotification } from "./notifications";
import { logger } from "./logger";
import { getDirectorySize } from "@main/events/helpers/get-directory-size";
import { GameExecutables } from "./game-executables";
const PROGRESS_THROTTLE_MS = 1000;
@@ -143,6 +144,17 @@ export class GameFilesManager {
extractionProgress: 0,
});
// Calculate and store the installed size
if (game && download.folderName) {
const gamePath = path.join(download.downloadPath, download.folderName);
const installedSizeInBytes = await getDirectorySize(gamePath);
await gamesSublevel.put(this.gameKey, {
...game,
installedSizeInBytes,
});
}
WindowManager.mainWindow?.webContents.send(
"on-extraction-complete",
this.shop,

View File

@@ -1,5 +1,6 @@
import axios from "axios";
import http from "node:http";
import getPort, { portNumbers } from "get-port";
import cp from "node:child_process";
import fs from "node:fs";
@@ -27,11 +28,17 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
win32: "hydra-python-rpc.exe",
};
const RPC_PORT_RANGE_START = 8080;
const RPC_PORT_RANGE_END = 9000;
const DEFAULT_RPC_PORT = 8084;
const HEALTH_CHECK_INTERVAL_MS = 100;
const HEALTH_CHECK_TIMEOUT_MS = 10000;
export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881";
public static readonly RPC_PORT = "8084";
public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`,
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
@@ -62,15 +69,46 @@ export class PythonRPC {
return newPassword;
}
private static async waitForHealthCheck(): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
try {
const response = await this.rpc.get("/healthcheck", { timeout: 1000 });
if (response.status === 200) {
pythonRpcLogger.log("RPC health check passed");
return;
}
} catch {
// Server not ready yet, continue polling
}
await new Promise((resolve) =>
setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS)
);
}
throw new Error("RPC health check timed out");
}
public static async spawn(
initialDownload?: GamePayload,
initialSeeding?: GamePayload[]
) {
const rpcPassword = await this.getRPCPassword();
const port = await getPort({
port: [
DEFAULT_RPC_PORT,
...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END),
],
});
this.rpc.defaults.baseURL = `http://localhost:${port}`;
pythonRpcLogger.log(`Using RPC port: ${port}`);
const commonArgs = [
this.BITTORRENT_PORT,
this.RPC_PORT,
String(port),
rpcPassword,
initialDownload ? JSON.stringify(initialDownload) : "",
initialSeeding ? JSON.stringify(initialSeeding) : "",
@@ -91,6 +129,7 @@ export class PythonRPC {
);
app.quit();
return;
}
const childProcess = cp.spawn(binaryPath, commonArgs, {
@@ -99,7 +138,6 @@ export class PythonRPC {
});
this.logStderr(childProcess.stderr);
this.pythonProcess = childProcess;
} else {
const scriptPath = path.join(
@@ -115,11 +153,23 @@ export class PythonRPC {
});
this.logStderr(childProcess.stderr);
this.pythonProcess = childProcess;
}
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
try {
await this.waitForHealthCheck();
pythonRpcLogger.log(`Python RPC started successfully on port ${port}`);
} catch (err) {
pythonRpcLogger.log(`Failed to start Python RPC: ${err}`);
dialog.showErrorBox(
"RPC Error",
`Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.`
);
this.kill();
throw err;
}
}
public static kill() {

View File

@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom";
import { BellIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
@@ -20,51 +20,60 @@ export function SidebarProfile() {
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0);
const apiNotificationCountRef = useRef(0);
const hasFetchedInitialCount = useRef(false);
const fetchNotificationCount = useCallback(async () => {
const fetchLocalNotificationCount = useCallback(async () => {
try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount();
// Fetch API notification count only if logged in
let apiCount = 0;
if (userDetails) {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiCount = response.count;
} catch {
// Ignore API errors
}
}
setNotificationCount(localCount + apiCount);
setNotificationCount(localCount + apiNotificationCountRef.current);
} catch (error) {
logger.error("Failed to fetch notification count", error);
logger.error("Failed to fetch local notification count", error);
}
}, [userDetails]);
}, []);
const fetchApiNotificationCount = useCallback(async () => {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiNotificationCountRef.current = response.count;
} catch {
// Ignore API errors
}
fetchLocalNotificationCount();
}, [fetchLocalNotificationCount]);
// Initial fetch on mount (only once)
useEffect(() => {
fetchNotificationCount();
fetchLocalNotificationCount();
}, [fetchLocalNotificationCount]);
const interval = setInterval(fetchNotificationCount, 60000);
return () => clearInterval(interval);
}, [fetchNotificationCount]);
// Fetch API count when user logs in (only if not already fetched)
useEffect(() => {
if (userDetails && !hasFetchedInitialCount.current) {
hasFetchedInitialCount.current = true;
fetchApiNotificationCount();
} else if (!userDetails) {
hasFetchedInitialCount.current = false;
apiNotificationCountRef.current = 0;
fetchLocalNotificationCount();
}
}, [userDetails, fetchApiNotificationCount, fetchLocalNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount();
fetchLocalNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
}, [fetchLocalNotificationCount]);
useEffect(() => {
const handleNotificationsChange = () => {
fetchNotificationCount();
fetchLocalNotificationCount();
};
window.addEventListener("notificationsChanged", handleNotificationsChange);
@@ -74,15 +83,18 @@ export function SidebarProfile() {
handleNotificationsChange
);
};
}, [fetchNotificationCount]);
}, [fetchLocalNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => {
fetchNotificationCount();
});
const unsubscribe = window.electron.onSyncNotificationCount(
(notification) => {
apiNotificationCountRef.current = notification.notificationCount;
fetchLocalNotificationCount();
}
);
return () => unsubscribe();
}, [fetchNotificationCount]);
}, [fetchLocalNotificationCount]);
const handleProfileClick = () => {
if (userDetails === null) {

View File

@@ -84,6 +84,45 @@
gap: calc(globals.$spacing-unit);
}
&__size-badges {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 6px 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
min-height: 28px;
box-sizing: border-box;
}
&__size-bar {
display: flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.95);
}
&__size-bar-line {
height: 4px;
border-radius: 2px;
transition: width 0.3s ease;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.5),
rgba(255, 255, 255, 0.8)
);
}
&__size-bar-text {
font-size: 12px;
font-weight: 500;
}
&__logo-container {
flex: 1;
display: flex;

View File

@@ -1,7 +1,15 @@
import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
import { formatBytes } from "@renderer/utils";
import {
ClockIcon,
AlertFillIcon,
TrophyIcon,
DatabaseIcon,
FileZipIcon,
} from "@primer/octicons-react";
import { memo, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import "./library-game-card-large.scss";
interface LibraryGameCardLargeProps {
@@ -30,9 +38,53 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
game,
onContextMenu,
}: Readonly<LibraryGameCardLargeProps>) {
const { t } = useTranslation("library");
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu);
const sizeBars = useMemo(() => {
const items: {
type: "installer" | "installed";
bytes: number;
formatted: string;
icon: typeof FileZipIcon;
tooltipKey: string;
}[] = [];
if (game.installerSizeInBytes) {
items.push({
type: "installer",
bytes: game.installerSizeInBytes,
formatted: formatBytes(game.installerSizeInBytes),
icon: FileZipIcon,
tooltipKey: "installer_size_tooltip",
});
}
if (game.installedSizeInBytes) {
items.push({
type: "installed",
bytes: game.installedSizeInBytes,
formatted: formatBytes(game.installedSizeInBytes),
icon: DatabaseIcon,
tooltipKey: "disk_usage_tooltip",
});
}
if (items.length === 0) return [];
// Sort by size descending (larger first)
items.sort((a, b) => b.bytes - a.bytes);
// Calculate proportional widths in pixels (max bar is 80px)
const maxBytes = items[0].bytes;
const maxWidth = 80;
return items.map((item) => ({
...item,
widthPx: Math.round((item.bytes / maxBytes) * maxWidth),
}));
}, [game.installerSizeInBytes, game.installedSizeInBytes]);
const backgroundImage = useMemo(
() =>
getImageWithCustomPriority(
@@ -94,6 +146,27 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
{sizeBars.length > 0 && (
<div className="library-game-card-large__size-badges">
{sizeBars.map((bar) => (
<div
key={bar.type}
className="library-game-card-large__size-bar"
title={t(bar.tooltipKey)}
>
<bar.icon size={11} />
<div
className={`library-game-card-large__size-bar-line library-game-card-large__size-bar-line--${bar.type}`}
style={{ width: `${bar.widthPx}px` }}
/>
<span className="library-game-card-large__size-bar-text">
{bar.formatted}
</span>
</div>
))}
</div>
)}
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon

View File

@@ -0,0 +1,18 @@
/**
* Converts a number of bytes into a human-readable string with appropriate units
* @param bytes - The number of bytes to format
* @param decimals - Number of decimal places (default: 2)
* @returns Formatted string like "1.5 GB", "256 MB", etc.
*/
export const formatBytes = (bytes: number, decimals = 2): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const index = Math.min(i, sizes.length - 1);
return `${parseFloat((bytes / Math.pow(k, index)).toFixed(dm))} ${sizes[index]}`;
};

View File

@@ -0,0 +1 @@
export * from "./format-bytes";

View File

@@ -64,6 +64,8 @@ export interface Game {
automaticCloudSync?: boolean;
hasManuallyUpdatedPlaytime?: boolean;
newDownloadOptionsCount?: number;
installedSizeInBytes?: number | null;
installerSizeInBytes?: number | null;
}
export interface Download {

View File

@@ -5587,6 +5587,11 @@ get-nonce@^1.0.0:
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-port@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec"
integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==
get-proto@^1.0.0, get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"