Compare commits

...

8 Commits

Author SHA1 Message Date
Chubby Granny Chaser
47bfc1648f Merge branch 'main' into fix/fixing-level-events 2025-12-10 17:34:01 +00:00
Chubby Granny Chaser
b5445b3dfa chore: updating lock 2025-12-01 06:51:08 +00:00
Chubby Granny Chaser
1ccf70af12 chore: updating lock 2025-11-30 23:57:13 +00:00
Chubby Granny Chaser
bb45b95820 chore: updating lock 2025-11-30 23:39:47 +00:00
Chubby Granny Chaser
361c158a44 fix: fixing level events 2025-11-30 23:17:56 +00:00
Chubby Granny Chaser
1f5e84b32c fix: fixing level events 2025-11-30 23:17:09 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
35 changed files with 300 additions and 463 deletions

View File

@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",

View File

@@ -414,7 +414,11 @@
"resume_seeding": "Resume seeding",
"options": "Manage",
"extract": "Extract files",
"extracting": "Extracting files…"
"extracting": "Extracting files…",
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Downloads path",

View File

@@ -414,7 +414,11 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Ruta de descarga",

View File

@@ -402,7 +402,11 @@
"resume_seeding": "Semear",
"options": "Gerenciar",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
"extracting": "Extraindo arquivos…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -229,7 +229,13 @@
"seeding": "A semear",
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Opções"
"options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Local das transferências",

View File

@@ -414,7 +414,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",

View File

@@ -1,20 +0,0 @@
import jwt from "jsonwebtoken";
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
if (!payload) return null;
return payload.sessionId;
};
registerEvent("getSessionHash", getSessionHash);

View File

@@ -1,3 +1,2 @@
import "./get-session-hash";
import "./open-auth-window";
import "./sign-out";

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesCheckBaseline } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesCheckBaselineHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesCheckBaseline();
};
registerEvent(
"getDownloadSourcesCheckBaseline",
getDownloadSourcesCheckBaselineHandler
);

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesSinceValue } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesSinceValueHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesSinceValue();
};
registerEvent(
"getDownloadSourcesSinceValue",
getDownloadSourcesSinceValueHandler
);

View File

@@ -1,10 +0,0 @@
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
const allSources = await downloadSourcesSublevel.values().all();
return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -1,6 +1,3 @@
import "./add-download-source";
import "./get-download-sources-check-baseline";
import "./get-download-sources-since-value";
import "./get-download-sources";
import "./remove-download-source";
import "./sync-download-sources";

View File

@@ -1,27 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { logger } from "@main/services";
import type { GameShop } from "@types";
const clearNewDownloadOptions = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
try {
await gamesSublevel.put(gameKey, {
...game,
newDownloadOptionsCount: undefined,
});
logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`);
} catch (error) {
logger.error(`Failed to clear newDownloadOptionsCount: ${error}`);
}
};
registerEvent("clearNewDownloadOptions", clearNewDownloadOptions);

View File

@@ -1,21 +0,0 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const [game, download] = await Promise.all([
gamesSublevel.get(gameKey),
downloadsSublevel.get(gameKey),
]);
if (!game || game.isDeleted) return null;
return { id: gameKey, ...game, download };
};
registerEvent("getGameByObjectId", getGameByObjectId);

View File

@@ -1,49 +0,0 @@
import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event";
import {
downloadsSublevel,
gameAchievementsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => {
return gamesSublevel
.iterator()
.all()
.then((results) => {
return Promise.all(
results
.filter(([_key, game]) => game.isDeleted === false)
.map(async ([key, game]) => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
if (!game.unlockedAchievementCount) {
const achievements = await gameAchievementsSublevel.get(key);
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount,
achievementCount: game.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
};
})
);
});
};
registerEvent("getLibrary", getLibrary);

View File

@@ -3,7 +3,6 @@ import "./add-game-to-favorites";
import "./add-game-to-library";
import "./change-game-playtime";
import "./cleanup-unused-assets";
import "./clear-new-download-options";
import "./close-game";
import "./copy-custom-game-asset";
import "./create-game-shortcut";
@@ -11,8 +10,6 @@ import "./create-steam-shortcut";
import "./delete-game-folder";
import "./extract-game-download";
import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id";
import "./get-library";
import "./open-game-executable-path";
import "./open-game-installer-path";
import "./open-game-installer";

View File

@@ -1,12 +0,0 @@
import { Theme } from "@types";
import { registerEvent } from "../register-event";
import { themesSublevel } from "@main/level";
const addCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
theme: Theme
) => {
await themesSublevel.put(theme.id, theme);
};
registerEvent("addCustomTheme", addCustomTheme);

View File

@@ -1,8 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
await themesSublevel.clear();
};
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);

View File

@@ -1,11 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const deleteCustomTheme = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
await themesSublevel.del(themeId);
};
registerEvent("deleteCustomTheme", deleteCustomTheme);

View File

@@ -1,9 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getActiveCustomTheme = async () => {
const allThemes = await themesSublevel.values().all();
return allThemes.find((theme) => theme.isActive);
};
registerEvent("getActiveCustomTheme", getActiveCustomTheme);

View File

@@ -1,8 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
return themesSublevel.values().all();
};
registerEvent("getAllCustomThemes", getAllCustomThemes);

View File

@@ -1,11 +0,0 @@
import { themesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const getCustomThemeById = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
) => {
return themesSublevel.get(themeId);
};
registerEvent("getCustomThemeById", getCustomThemeById);

View File

@@ -1,11 +1,5 @@
import "./add-custom-theme";
import "./close-editor-window";
import "./copy-theme-achievement-sound";
import "./delete-all-custom-themes";
import "./delete-custom-theme";
import "./get-active-custom-theme";
import "./get-all-custom-themes";
import "./get-custom-theme-by-id";
import "./get-theme-sound-data-url";
import "./get-theme-sound-path";
import "./import-theme-sound-from-store";

View File

@@ -1,10 +0,0 @@
import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
const getUserPreferences = async () =>
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
});
registerEvent("getUserPreferences", getUserPreferences);

View File

@@ -1,5 +1,4 @@
import "./authenticate-real-debrid";
import "./authenticate-torbox";
import "./auto-launch";
import "./get-user-preferences";
import "./update-user-preferences";

View File

@@ -1,11 +0,0 @@
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { registerEvent } from "../register-event";
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
db.get<string, Auth>(levelKeys.auth, {
valueEncoding: "json",
});
registerEvent("getAuth", getAuth);

View File

@@ -1,3 +1,2 @@
import "./get-auth";
import "./get-compared-unlocked-achievements";
import "./get-unlocked-achievements";

View File

@@ -13,7 +13,6 @@ import type {
UpdateProfileRequest,
SeedingStatus,
GameAchievement,
Theme,
FriendRequestSync,
ShortcutLocation,
AchievementCustomNotificationPosition,
@@ -86,7 +85,8 @@ contextBridge.exposeInMainWorld("electron", {
},
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
getUserPreferences: () =>
ipcRenderer.invoke("leveldbGet", "userPreferences", null, "json"),
updateUserPreferences: (preferences: UserPreferences) =>
ipcRenderer.invoke("updateUserPreferences", preferences),
autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) =>
@@ -101,12 +101,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */
toggleAutomaticCloudSync: (
@@ -183,8 +178,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: (
@@ -201,7 +194,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath),
verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId),
@@ -230,8 +222,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeGame", shop, objectId),
deleteGameFolder: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("deleteGameFolder", shop, objectId),
getGameByObjectId: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
@@ -287,8 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
gameArtifactId: string
) =>
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
getGameArtifacts: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
selectGameBackupPath: (
@@ -503,11 +491,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
/* Auth */
getAuth: () => ipcRenderer.invoke("getAuth"),
getAuth: () => ipcRenderer.invoke("leveldbGet", "auth", null, "json"),
signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: (page: AuthPage) =>
ipcRenderer.invoke("openAuthWindow", page),
getSessionHash: () => ipcRenderer.invoke("getSessionHash"),
onSignIn: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-signin", listener);
@@ -565,16 +552,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("showAchievementTestNotification"),
/* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
deleteCustomTheme: (themeId: string) =>
ipcRenderer.invoke("deleteCustomTheme", themeId),
updateCustomTheme: (themeId: string, code: string) =>
ipcRenderer.invoke("updateCustomTheme", themeId, code),
getCustomThemeById: (themeId: string) =>
ipcRenderer.invoke("getCustomThemeById", themeId),
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
copyThemeAchievementSound: (themeId: string, sourcePath: string) =>

View File

@@ -7,11 +7,13 @@ import {
useToast,
useUserDetails,
} from "@renderer/hooks";
import { levelDBService } from "@renderer/services/leveldb.service";
import "./bottom-panel.scss";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
import type jwt from "jsonwebtoken";
export function BottomPanel() {
const { t } = useTranslation("bottom_panel");
@@ -60,7 +62,28 @@ export function BottomPanel() {
}, [t, showSuccessToast]);
useEffect(() => {
window.electron.getSessionHash().then((result) => setSessionHash(result));
const getSessionHash = async () => {
const auth = (await levelDBService.get("auth", null, "json")) as {
accessToken?: string;
} | null;
if (!auth?.accessToken) {
setSessionHash(null);
return;
}
try {
const jwtModule = await import("jsonwebtoken");
const payload = jwtModule.decode(
auth.accessToken
) as jwt.JwtPayload | null;
setSessionHash(payload?.sessionId ?? null);
} catch {
setSessionHash(null);
}
};
getSessionHash();
}, [userDetails?.id]);
const status = useMemo(() => {

View File

@@ -12,6 +12,7 @@ import {
} from "@renderer/hooks";
import type {
Download,
DownloadSource,
GameRepack,
GameShop,
@@ -95,9 +96,19 @@ export function GameDetailsContextProvider({
);
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectId(shop, objectId)
.then((result) => setGame(result));
const gameKey = `${shop}:${objectId}`;
const [game, download] = await Promise.all([
levelDBService.get(gameKey, "games") as Promise<LibraryGame | null>,
levelDBService.get(gameKey, "downloads") as Promise<Download | null>,
]);
if (!game || game.isDeleted) {
setGame(null);
return;
}
const { id: _id, ...gameWithoutId } = game;
setGame({ id: gameKey, ...gameWithoutId, download: download ?? null });
}, [shop, objectId]);
const isGameDownloading =

View File

@@ -14,14 +14,11 @@ import type {
GameStats,
UserDetails,
FriendRequestSync,
GameArtifact,
LudusaviBackup,
UserAchievement,
ComparedAchievements,
LibraryGame,
GameRunning,
TorBoxUser,
Theme,
Auth,
ShortcutLocation,
ShopAssets,
@@ -142,10 +139,6 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
@@ -162,7 +155,6 @@ declare global {
winePrefixPath: string | null
) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -177,10 +169,6 @@ declare global {
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
removeGame: (shop: GameShop, objectId: string) => Promise<void>;
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
getGameByObjectId: (
shop: GameShop,
objectId: string
) => Promise<LibraryGame | null>;
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -194,9 +182,9 @@ declare global {
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
preferences: Partial<UserPreferences>
) => Promise<void>;
@@ -217,10 +205,7 @@ declare global {
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -237,10 +222,6 @@ declare global {
shop: GameShop,
gameArtifactId: string
) => Promise<void>;
getGameArtifacts: (
objectId: string,
shop: GameShop
) => Promise<GameArtifact[]>;
getGameBackupPreview: (
objectId: string,
shop: GameShop
@@ -355,7 +336,6 @@ declare global {
getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
@@ -408,13 +388,7 @@ declare global {
showAchievementTestNotification: () => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: (
themeId: string,

View File

@@ -1,15 +1,65 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import { setLibrary } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import type {
LibraryGame,
Game,
Download,
ShopAssets,
GameAchievement,
} from "@types";
export function useLibrary() {
const dispatch = useAppDispatch();
const library = useAppSelector((state) => state.library.value);
const updateLibrary = useCallback(async () => {
return window.electron
.getLibrary()
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
const results = await levelDBService.iterator("games");
const libraryGames = await Promise.all(
results
.filter(([_key, game]) => (game as Game).isDeleted === false)
.map(async ([key, game]) => {
const gameData = game as Game;
const download = (await levelDBService.get(
key,
"downloads"
)) as Download | null;
const gameAssets = (await levelDBService.get(
key,
"gameShopAssets"
)) as (ShopAssets & { updatedAt: number }) | null;
let unlockedAchievementCount = gameData.unlockedAchievementCount ?? 0;
if (!gameData.unlockedAchievementCount) {
const achievements = (await levelDBService.get(
key,
"gameAchievements"
)) as GameAchievement | null;
unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0;
}
return {
id: key,
...gameData,
download: download ?? null,
unlockedAchievementCount,
achievementCount: gameData.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Preserve custom image URLs from game if they exist
customIconUrl: gameData.customIconUrl,
customLogoImageUrl: gameData.customLogoImageUrl,
customHeroImageUrl: gameData.customHeroImageUrl,
} as LibraryGame;
})
);
dispatch(setLibrary(libraryGames));
}, [dispatch]);
return { library, updateLibrary };

View File

@@ -305,9 +305,11 @@ function HeroDownloadView({
)}
</span>
)}
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
{(!lastPacket?.isCheckingFiles || currentProgress > 0) && (
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
)}
</div>
<div className="download-group__progress-bar">
<div
@@ -358,7 +360,7 @@ function HeroDownloadView({
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">
{t("network")}:
{t("network")}
</span>
<span className="download-group__stat-value">
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
@@ -371,37 +373,38 @@ function HeroDownloadView({
<GraphIcon size={16} />
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">{t("peak")}:</span>
<span className="download-group__stat-label">{t("peak")}</span>
<span className="download-group__stat-value">
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
</span>
</div>
</div>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<span className="download-group__stat-label">
Seeds:{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, Peers:{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
</div>
</div>
)}
{game.download?.downloader && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<div
className="download-group__stat-content"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<span className="download-group__stat-label">
{t("seeds")}{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, {t("peers")}{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
)}
</div>
</div>
)}
@@ -436,6 +439,7 @@ export function DownloadGroup({
seedingStatus,
}: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads");
const navigate = useNavigate();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
@@ -867,12 +871,36 @@ export function DownloadGroup({
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return (
<li key={game.id} className="download-group__simple-card">
<div className="download-group__simple-thumbnail">
<button
type="button"
className="download-group__simple-thumbnail"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</div>
</button>
<div className="download-group__simple-info">
<h3 className="download-group__simple-title">{game.title}</h3>
<button
type="button"
className="download-group__simple-title"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
textAlign: "left",
width: "100%",
}}
>
{game.title}
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>

View File

@@ -87,12 +87,16 @@ export function LibraryTab({
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<li
key={game.objectId}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</li>
@@ -134,6 +138,9 @@ export function LibraryTab({
<motion.li
key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
initial={
isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 }
@@ -160,8 +167,6 @@ export function LibraryTab({
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</motion.li>

View File

@@ -25,16 +25,12 @@ import "./user-library-game-card.scss";
interface UserLibraryGameCardProps {
game: UserGame;
statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
sortBy?: string;
}
export function UserLibraryGameCard({
game,
statIndex,
onMouseEnter,
onMouseLeave,
sortBy,
}: UserLibraryGameCardProps) {
const { userProfile, isMe, getUserLibraryGames } =
@@ -130,129 +126,126 @@ export function UserLibraryGameCard({
return (
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
<div
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(buildUserGameDetailsPath(game));
}
}}
role="button"
tabIndex={0}
title={isTooltipHovered ? undefined : game.title}
>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{isMe && (
<div className="user-library-game__actions-container">
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
<div className="user-library-game__overlay">
{isMe && (
<div className="user-library-game__actions-container">
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
</div>
{userProfile?.hasActiveSubscription &&
game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
{game.unlockedAchievementCount} / {game.achievementCount}
</span>
</div>
<progress
max={1}
value={
game.unlockedAchievementCount / game.achievementCount
}
className="user-library-game__achievements-progress"
/>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
)}
</div>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className="user-library-game__achievements-progress"
/>
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
</li>
</div>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</div>
<Tooltip
id={game.objectId}
style={{