From 101bc3546086123c80dce7279bac273407992a86 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:21:31 +0200 Subject: [PATCH 01/36] feat: sidebar badge on new game download option --- .../downloadSourcesCheckTimestamp.ts | 16 +++ src/main/level/sublevels/index.ts | 1 + src/main/level/sublevels/keys.ts | 1 + src/main/main.ts | 4 + src/main/services/download-sources-checker.ts | 127 ++++++++++++++++++ src/main/services/hydra-api.ts | 32 +++++ src/main/services/index.ts | 1 + src/preload/index.ts | 11 ++ src/renderer/src/app.tsx | 4 + .../components/sidebar/sidebar-game-item.tsx | 7 + .../src/components/sidebar/sidebar.scss | 20 +++ src/renderer/src/declaration.d.ts | 5 + src/renderer/src/features/library-slice.ts | 20 ++- src/renderer/src/hooks/index.ts | 1 + .../hooks/use-download-options-listener.ts | 19 +++ src/types/level.types.ts | 1 + 16 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/main/level/sublevels/downloadSourcesCheckTimestamp.ts create mode 100644 src/main/services/download-sources-checker.ts create mode 100644 src/renderer/src/hooks/use-download-options-listener.ts diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts new file mode 100644 index 00000000..13dbf682 --- /dev/null +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -0,0 +1,16 @@ +import { levelKeys } from "./keys"; +import { db } from "../level"; + +export const getLastDownloadSourcesCheck = async (): Promise => { + try { + const timestamp = await db.get(levelKeys.lastDownloadSourcesCheck); + return timestamp; + } catch (error) { + // Key doesn't exist yet + return null; + } +}; + +export const updateLastDownloadSourcesCheck = async (timestamp: string): Promise => { + await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); +}; \ No newline at end of file diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 3619ae26..4575bbc4 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,3 +7,4 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; +export * from "./downloadSourcesCheckTimestamp"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index a28690b2..536f9dca 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,4 +18,5 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", + lastDownloadSourcesCheck: "lastDownloadSourcesCheck", }; diff --git a/src/main/main.ts b/src/main/main.ts index ffb8f8a9..8e1264d7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,6 +16,7 @@ import { Ludusavi, Lock, DeckyPlugin, + DownloadSourcesChecker, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -56,6 +57,9 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); + + // Check for new download options on startup + void DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); }); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts new file mode 100644 index 00000000..f22f12fc --- /dev/null +++ b/src/main/services/download-sources-checker.ts @@ -0,0 +1,127 @@ +import { HydraApi } from "./hydra-api"; +import { gamesSublevel, getLastDownloadSourcesCheck, updateLastDownloadSourcesCheck, downloadSourcesSublevel } from "@main/level"; +import { logger } from "./logger"; +import { WindowManager } from "./window-manager"; +import type { Game } from "@types"; + +interface DownloadSourcesChangeResponse { + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; +} + +export class DownloadSourcesChecker { + static async checkForChanges(): Promise { + logger.info("DownloadSourcesChecker.checkForChanges() called"); + + try { + // Get all installed games (excluding custom games) + const installedGames = await gamesSublevel.values().all(); + const nonCustomGames = installedGames.filter((game: Game) => game.shop !== 'custom'); + logger.info(`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`); + + if (nonCustomGames.length === 0) { + logger.info("No non-custom games found, skipping download sources check"); + return; + } + + // Get download sources + const downloadSources = await downloadSourcesSublevel.values().all(); + const downloadSourceIds = downloadSources.map(source => source.id); + logger.info(`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(', ')}`); + + if (downloadSourceIds.length === 0) { + logger.info("No download sources found, skipping download sources check"); + return; + } + + // Get last check timestamp or use a default (24 hours ago) + const lastCheck = await getLastDownloadSourcesCheck(); + const since = lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + logger.info(`Last check: ${lastCheck}, using since: ${since}`); + + // Clear any previously stored new download option counts so badges don't persist across restarts + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => (game as Game).newDownloadOptionsCount && (game as Game).newDownloadOptionsCount! > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info(`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ gameId: `${game.shop}:${game.objectId}`, count: 0 }); + } + } + + // Prepare games array for API call (excluding custom games) + const games = nonCustomGames.map((game: Game) => ({ + shop: game.shop, + objectId: game.objectId + })); + + logger.info(`Checking download sources changes for ${games.length} non-custom games since ${since}`); + logger.info(`Making API call to HydraApi.checkDownloadSourcesChanges with:`, { + downloadSourceIds, + gamesCount: games.length, + since + }); + + // Call the API + const response = await HydraApi.checkDownloadSourcesChanges( + downloadSourceIds, + games, + since + ); + + logger.info("API call completed, response:", response); + + // Update the last check timestamp + await updateLastDownloadSourcesCheck(new Date().toISOString()); + + // Process the response and store newDownloadOptionsCount for games with new options + if (response && Array.isArray(response)) { + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find(g => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + // Store the new download options count in the game data + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount + }); + + logger.info(`Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options`); + } + } + } + + // Send IPC event to renderer to clear stale badges and set fresh counts from response + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-new-download-options", eventPayload); + } + + logger.info(`Found new download options for ${gamesWithNewOptions.length} games`); + } + + logger.info("Download sources check completed successfully"); + } catch (error) { + logger.error("Failed to check download sources changes:", error); + } + } +} \ No newline at end of file diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 12090df3..4f7091db 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -399,4 +399,36 @@ export class HydraApi { .then((response) => response.data) .catch(this.handleUnauthorizedError); } + + static async checkDownloadSourcesChanges( + downloadSourceIds: string[], + games: Array<{ shop: string; objectId: string }>, + since: string + ) { + logger.info("HydraApi.checkDownloadSourcesChanges called with:", { + downloadSourceIds, + gamesCount: games.length, + since, + isLoggedIn: this.isLoggedIn() + }); + + try { + const result = await this.post>("/download-sources/changes", { + downloadSourceIds, + games, + since, + }, { needsAuth: true }); + + logger.info("HydraApi.checkDownloadSourcesChanges completed successfully:", result); + return result; + } catch (error) { + logger.error("HydraApi.checkDownloadSourcesChanges failed:", error); + throw error; + } + } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index da4e6848..a3891dc6 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -19,3 +19,4 @@ export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; export * from "./user"; +export * from "./download-sources-checker"; diff --git a/src/preload/index.ts b/src/preload/index.ts index f89ec4db..a1a0d959 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -580,6 +580,17 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-custom-theme-updated", listener); }, + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + gamesWithNewOptions: { gameId: string; count: number }[] + ) => cb(gamesWithNewOptions); + ipcRenderer.on("on-new-download-options", listener); + return () => + ipcRenderer.removeListener("on-new-download-options", listener); + }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 168a4435..274e95db 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -10,6 +10,7 @@ import { useToast, useUserDetails, } from "@renderer/hooks"; +import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { @@ -36,6 +37,9 @@ export function App() { const contentRef = useRef(null); const { updateLibrary, library } = useLibrary(); + // Listen for new download options updates + useDownloadOptionsListener(); + const { t } = useTranslation("app"); const { clearDownload, setLastPacket } = useDownload(); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 356aa913..add7e081 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -80,6 +80,13 @@ export function SidebarGameItem({ {getGameTitle(game)} + + {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && ( + +
+
+
{game.newDownloadOptionsCount}
+
+ )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 49f6e007..9068e2b5 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -115,6 +115,26 @@ background-size: cover; } + &__game-badge { + background: rgba(255, 255, 255, 0.1);; + color: #fff; + font-size: 10px; + font-weight: bold; + padding: 4px 6px; + border-radius: 6px; + display: flex; + margin-left: auto; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + gap: calc(globals.$spacing-unit * 0.35); + } + + &__game-badge-plus, + &__game-badge-count { + display: flex; + align-items: center; + justify-content: center; + } + &__section-header { display: flex; justify-content: space-between; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index fa4ab3d6..30b5c67a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -414,6 +414,11 @@ declare global { openEditorWindow: (themeId: string) => Promise; onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer; closeEditorWindow: (themeId?: string) => Promise; + + /* Download Options */ + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => () => Electron.IpcRenderer; } interface Window { diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index 6c95aa79..e92e6a25 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -18,7 +18,25 @@ export const librarySlice = createSlice({ setLibrary: (state, action: PayloadAction) => { state.value = action.payload; }, + updateGameNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string; count: number }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = action.payload.count; + } + }, + clearNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = undefined; + } + }, }, }); -export const { setLibrary } = librarySlice.actions; +export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions } = librarySlice.actions; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 73733e2b..2d2ee02f 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -6,3 +6,4 @@ export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; export * from "./use-feature"; +export * from "./use-download-options-listener"; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts new file mode 100644 index 00000000..f0268335 --- /dev/null +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useAppDispatch } from "./redux"; +import { updateGameNewDownloadOptions } from "@renderer/features"; + +export function useDownloadOptionsListener() { + const dispatch = useAppDispatch(); + + useEffect(() => { + const unsubscribe = window.electron.onNewDownloadOptions( + (gamesWithNewOptions) => { + gamesWithNewOptions.forEach(({ gameId, count }) => { + dispatch(updateGameNewDownloadOptions({ gameId, count })); + }); + } + ); + + return unsubscribe; + }, [dispatch]); +} \ No newline at end of file diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 1df55b9e..ff602ac9 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -59,6 +59,7 @@ export interface Game { pinnedDate?: Date | null; automaticCloudSync?: boolean; hasManuallyUpdatedPlaytime?: boolean; + newDownloadOptionsCount?: number; } export interface Download { From 4dd3c9de76e0765a3b001c01577bbf2cf56d7a33 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:26:22 +0200 Subject: [PATCH 02/36] fix: formatting --- .../downloadSourcesCheckTimestamp.ts | 6 +- src/main/main.ts | 2 +- src/main/services/download-sources-checker.ts | 106 ++++++++++++------ src/main/services/hydra-api.ts | 35 +++--- .../components/sidebar/sidebar-game-item.tsx | 4 +- .../src/components/sidebar/sidebar.scss | 2 +- src/renderer/src/features/library-slice.ts | 6 +- .../hooks/use-download-options-listener.ts | 2 +- 8 files changed, 108 insertions(+), 55 deletions(-) diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index 13dbf682..f7071932 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -11,6 +11,8 @@ export const getLastDownloadSourcesCheck = async (): Promise => { } }; -export const updateLastDownloadSourcesCheck = async (timestamp: string): Promise => { +export const updateLastDownloadSourcesCheck = async ( + timestamp: string +): Promise => { await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); -}; \ No newline at end of file +}; diff --git a/src/main/main.ts b/src/main/main.ts index 8e1264d7..50173390 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -57,7 +57,7 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); - + // Check for new download options on startup void DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index f22f12fc..d2ccb531 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -1,5 +1,10 @@ import { HydraApi } from "./hydra-api"; -import { gamesSublevel, getLastDownloadSourcesCheck, updateLastDownloadSourcesCheck, downloadSourcesSublevel } from "@main/level"; +import { + gamesSublevel, + getLastDownloadSourcesCheck, + updateLastDownloadSourcesCheck, + downloadSourcesSublevel, +} from "@main/level"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; import type { Game } from "@types"; @@ -14,62 +19,85 @@ interface DownloadSourcesChangeResponse { export class DownloadSourcesChecker { static async checkForChanges(): Promise { logger.info("DownloadSourcesChecker.checkForChanges() called"); - + try { // Get all installed games (excluding custom games) const installedGames = await gamesSublevel.values().all(); - const nonCustomGames = installedGames.filter((game: Game) => game.shop !== 'custom'); - logger.info(`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`); - + const nonCustomGames = installedGames.filter( + (game: Game) => game.shop !== "custom" + ); + logger.info( + `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games` + ); + if (nonCustomGames.length === 0) { - logger.info("No non-custom games found, skipping download sources check"); + logger.info( + "No non-custom games found, skipping download sources check" + ); return; } // Get download sources const downloadSources = await downloadSourcesSublevel.values().all(); - const downloadSourceIds = downloadSources.map(source => source.id); - logger.info(`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(', ')}`); + const downloadSourceIds = downloadSources.map((source) => source.id); + logger.info( + `Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(", ")}` + ); if (downloadSourceIds.length === 0) { - logger.info("No download sources found, skipping download sources check"); + logger.info( + "No download sources found, skipping download sources check" + ); return; } // Get last check timestamp or use a default (24 hours ago) const lastCheck = await getLastDownloadSourcesCheck(); - const since = lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const since = + lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); logger.info(`Last check: ${lastCheck}, using since: ${since}`); // Clear any previously stored new download option counts so badges don't persist across restarts const previouslyFlaggedGames = nonCustomGames.filter( - (game: Game) => (game as Game).newDownloadOptionsCount && (game as Game).newDownloadOptionsCount! > 0 + (game: Game) => + (game as Game).newDownloadOptionsCount && + (game as Game).newDownloadOptionsCount! > 0 ); const clearedPayload: { gameId: string; count: number }[] = []; if (previouslyFlaggedGames.length > 0) { - logger.info(`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`); + logger.info( + `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` + ); for (const game of previouslyFlaggedGames) { await gamesSublevel.put(`${game.shop}:${game.objectId}`, { ...game, newDownloadOptionsCount: undefined, }); - clearedPayload.push({ gameId: `${game.shop}:${game.objectId}`, count: 0 }); + clearedPayload.push({ + gameId: `${game.shop}:${game.objectId}`, + count: 0, + }); } } // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ shop: game.shop, - objectId: game.objectId + objectId: game.objectId, })); - logger.info(`Checking download sources changes for ${games.length} non-custom games since ${since}`); - logger.info(`Making API call to HydraApi.checkDownloadSourcesChanges with:`, { - downloadSourceIds, - gamesCount: games.length, - since - }); + logger.info( + `Checking download sources changes for ${games.length} non-custom games since ${since}` + ); + logger.info( + `Making API call to HydraApi.checkDownloadSourcesChanges with:`, + { + downloadSourceIds, + gamesCount: games.length, + since, + } + ); // Call the API const response = await HydraApi.checkDownloadSourcesChanges( @@ -77,7 +105,7 @@ export class DownloadSourcesChecker { games, since ); - + logger.info("API call completed, response:", response); // Update the last check timestamp @@ -86,37 +114,45 @@ export class DownloadSourcesChecker { // Process the response and store newDownloadOptionsCount for games with new options if (response && Array.isArray(response)) { const gamesWithNewOptions: { gameId: string; count: number }[] = []; - + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { if (gameUpdate.newDownloadOptionsCount > 0) { - const game = nonCustomGames.find(g => - g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId ); - + if (game) { // Store the new download options count in the game data await gamesSublevel.put(`${game.shop}:${game.objectId}`, { ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, }); - + gamesWithNewOptions.push({ gameId: `${game.shop}:${game.objectId}`, - count: gameUpdate.newDownloadOptionsCount + count: gameUpdate.newDownloadOptionsCount, }); - - logger.info(`Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options`); + + logger.info( + `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` + ); } } } - + // Send IPC event to renderer to clear stale badges and set fresh counts from response const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; if (eventPayload.length > 0 && WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send("on-new-download-options", eventPayload); + WindowManager.mainWindow.webContents.send( + "on-new-download-options", + eventPayload + ); } - - logger.info(`Found new download options for ${gamesWithNewOptions.length} games`); + + logger.info( + `Found new download options for ${gamesWithNewOptions.length} games` + ); } logger.info("Download sources check completed successfully"); @@ -124,4 +160,4 @@ export class DownloadSourcesChecker { logger.error("Failed to check download sources changes:", error); } } -} \ No newline at end of file +} diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 4f7091db..e7e93268 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -409,22 +409,31 @@ export class HydraApi { downloadSourceIds, gamesCount: games.length, since, - isLoggedIn: this.isLoggedIn() + isLoggedIn: this.isLoggedIn(), }); try { - const result = await this.post>("/download-sources/changes", { - downloadSourceIds, - games, - since, - }, { needsAuth: true }); - - logger.info("HydraApi.checkDownloadSourcesChanges completed successfully:", result); + const result = await this.post< + Array<{ + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; + }> + >( + "/download-sources/changes", + { + downloadSourceIds, + games, + since, + }, + { needsAuth: true } + ); + + logger.info( + "HydraApi.checkDownloadSourcesChanges completed successfully:", + result + ); return result; } catch (error) { logger.error("HydraApi.checkDownloadSourcesChanges failed:", error); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index add7e081..59698935 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -84,7 +84,9 @@ export function SidebarGameItem({ {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && (
+
-
{game.newDownloadOptionsCount}
+
+ {game.newDownloadOptionsCount} +
)} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 9068e2b5..fdb3872e 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -116,7 +116,7 @@ } &__game-badge { - background: rgba(255, 255, 255, 0.1);; + background: rgba(255, 255, 255, 0.1); color: #fff; font-size: 10px; font-weight: bold; diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index e92e6a25..9c399dbe 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -39,4 +39,8 @@ export const librarySlice = createSlice({ }, }); -export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions } = librarySlice.actions; +export const { + setLibrary, + updateGameNewDownloadOptions, + clearNewDownloadOptions, +} = librarySlice.actions; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts index f0268335..34cc7bf7 100644 --- a/src/renderer/src/hooks/use-download-options-listener.ts +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -16,4 +16,4 @@ export function useDownloadOptionsListener() { return unsubscribe; }, [dispatch]); -} \ No newline at end of file +} From efab242c745a8fa615f11c53a316fb1f8f1d21b6 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 31 Oct 2025 23:17:06 +0200 Subject: [PATCH 03/36] ci: showing new badge in repack-modal --- src/locales/en/translation.json | 1 + .../get-download-sources-check-baseline.ts | 13 ++ .../get-download-sources-since-value.ts | 13 ++ src/main/events/index.ts | 2 + .../downloadSourcesCheckTimestamp.ts | 51 ++++- src/main/level/sublevels/keys.ts | 3 +- src/main/services/download-sources-checker.ts | 180 +++++++++++------- src/preload/index.ts | 4 + src/renderer/src/declaration.d.ts | 2 + .../hooks/use-download-options-listener.ts | 4 +- .../game-details/modals/repacks-modal.scss | 15 ++ .../game-details/modals/repacks-modal.tsx | 48 ++++- src/types/index.ts | 1 + 13 files changed, 253 insertions(+), 84 deletions(-) create mode 100644 src/main/events/download-sources/get-download-sources-check-baseline.ts create mode 100644 src/main/events/download-sources/get-download-sources-since-value.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 668f1547..7710066d 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -194,6 +194,7 @@ "download_in_progress": "Download in progress", "download_paused": "Download paused", "last_downloaded_option": "Last downloaded option", + "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", "create_shortcut_success": "Shortcut created successfully", "you_might_need_to_restart_steam": "You might need to restart Steam to see the changes", diff --git a/src/main/events/download-sources/get-download-sources-check-baseline.ts b/src/main/events/download-sources/get-download-sources-check-baseline.ts new file mode 100644 index 00000000..2f3ab377 --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-check-baseline.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesCheckBaseline } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesCheckBaselineHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesCheckBaseline(); +}; + +registerEvent( + "getDownloadSourcesCheckBaseline", + getDownloadSourcesCheckBaselineHandler +); diff --git a/src/main/events/download-sources/get-download-sources-since-value.ts b/src/main/events/download-sources/get-download-sources-since-value.ts new file mode 100644 index 00000000..cbd06faf --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-since-value.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesSinceValue } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesSinceValueHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesSinceValue(); +}; + +registerEvent( + "getDownloadSourcesSinceValue", + getDownloadSourcesSinceValueHandler +); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0ab5499a..162b08d7 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -64,6 +64,8 @@ import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; import "./download-sources/sync-download-sources"; +import "./download-sources/get-download-sources-check-baseline"; +import "./download-sources/get-download-sources-since-value"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index f7071932..4b60b962 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -1,18 +1,59 @@ import { levelKeys } from "./keys"; import { db } from "../level"; +import { logger } from "@main/services"; -export const getLastDownloadSourcesCheck = async (): Promise => { +// Gets when we last started the app (for next API call's 'since') +export const getDownloadSourcesCheckBaseline = async (): Promise< + string | null +> => { try { - const timestamp = await db.get(levelKeys.lastDownloadSourcesCheck); + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); return timestamp; } catch (error) { - // Key doesn't exist yet + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources check baseline not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources check baseline", + error + ); + } return null; } }; -export const updateLastDownloadSourcesCheck = async ( +// Updates to current time (when app starts) +export const updateDownloadSourcesCheckBaseline = async ( timestamp: string ): Promise => { - await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); +}; + +// Gets the 'since' value the API used in the last check (for modal comparison) +export const getDownloadSourcesSinceValue = async (): Promise< + string | null +> => { + try { + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + return timestamp; + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources since value not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources since value", + error + ); + } + return null; + } +}; + +// Saves the 'since' value we used in the API call (for modal to compare against) +export const updateDownloadSourcesSinceValue = async ( + timestamp: string +): Promise => { + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); }; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 536f9dca..89c33f8d 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,5 +18,6 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", - lastDownloadSourcesCheck: "lastDownloadSourcesCheck", + downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app + downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison) }; diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index d2ccb531..f8b853a7 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -1,8 +1,9 @@ import { HydraApi } from "./hydra-api"; import { gamesSublevel, - getLastDownloadSourcesCheck, - updateLastDownloadSourcesCheck, + getDownloadSourcesCheckBaseline, + updateDownloadSourcesCheckBaseline, + updateDownloadSourcesSinceValue, downloadSourcesSublevel, } from "@main/level"; import { logger } from "./logger"; @@ -17,6 +18,89 @@ interface DownloadSourcesChangeResponse { } export class DownloadSourcesChecker { + private static async clearStaleBadges( + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => + game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info( + `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` + ); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ + gameId: `${game.shop}:${game.objectId}`, + count: 0, + }); + } + } + + return clearedPayload; + } + + private static async processApiResponse( + response: unknown, + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + if (!response || !Array.isArray(response)) { + return []; + } + + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount, + }); + + logger.info( + `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` + ); + } + } + } + + return gamesWithNewOptions; + } + + private static sendNewDownloadOptionsEvent( + clearedPayload: { gameId: string; count: number }[], + gamesWithNewOptions: { gameId: string; count: number }[] + ): void { + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send( + "on-new-download-options", + eventPayload + ); + } + + logger.info( + `Found new download options for ${gamesWithNewOptions.length} games` + ); + } + static async checkForChanges(): Promise { logger.info("DownloadSourcesChecker.checkForChanges() called"); @@ -51,35 +135,16 @@ export class DownloadSourcesChecker { return; } - // Get last check timestamp or use a default (24 hours ago) - const lastCheck = await getLastDownloadSourcesCheck(); + // Get when we LAST started the app (for this check's 'since' parameter) + const previousBaseline = await getDownloadSourcesCheckBaseline(); const since = - lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - logger.info(`Last check: ${lastCheck}, using since: ${since}`); + previousBaseline || + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + logger.info(`Using since: ${since} (from last app start)`); // Clear any previously stored new download option counts so badges don't persist across restarts - const previouslyFlaggedGames = nonCustomGames.filter( - (game: Game) => - (game as Game).newDownloadOptionsCount && - (game as Game).newDownloadOptionsCount! > 0 - ); - - const clearedPayload: { gameId: string; count: number }[] = []; - if (previouslyFlaggedGames.length > 0) { - logger.info( - `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` - ); - for (const game of previouslyFlaggedGames) { - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: undefined, - }); - clearedPayload.push({ - gameId: `${game.shop}:${game.objectId}`, - count: 0, - }); - } - } + const clearedPayload = await this.clearStaleBadges(nonCustomGames); // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ @@ -108,52 +173,25 @@ export class DownloadSourcesChecker { logger.info("API call completed, response:", response); - // Update the last check timestamp - await updateLastDownloadSourcesCheck(new Date().toISOString()); + // Save the 'since' value we just used (for modal to compare against) + await updateDownloadSourcesSinceValue(since); + logger.info(`Saved 'since' value: ${since} (for modal comparison)`); + + // Update baseline to NOW (for next app start's 'since') + const now = new Date().toISOString(); + await updateDownloadSourcesCheckBaseline(now); + logger.info( + `Updated baseline to: ${now} (will be 'since' on next app start)` + ); // Process the response and store newDownloadOptionsCount for games with new options - if (response && Array.isArray(response)) { - const gamesWithNewOptions: { gameId: string; count: number }[] = []; + const gamesWithNewOptions = await this.processApiResponse( + response, + nonCustomGames + ); - for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { - if (gameUpdate.newDownloadOptionsCount > 0) { - const game = nonCustomGames.find( - (g) => - g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId - ); - - if (game) { - // Store the new download options count in the game data - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, - }); - - gamesWithNewOptions.push({ - gameId: `${game.shop}:${game.objectId}`, - count: gameUpdate.newDownloadOptionsCount, - }); - - logger.info( - `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` - ); - } - } - } - - // Send IPC event to renderer to clear stale badges and set fresh counts from response - const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; - if (eventPayload.length > 0 && WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send( - "on-new-download-options", - eventPayload - ); - } - - logger.info( - `Found new download options for ${gamesWithNewOptions.length} games` - ); - } + // Send IPC event to renderer to clear stale badges and set fresh counts from response + this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); logger.info("Download sources check completed successfully"); } catch (error) { diff --git a/src/preload/index.ts b/src/preload/index.ts index a1a0d959..3a879d70 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), + getDownloadSourcesCheckBaseline: () => + ipcRenderer.invoke("getDownloadSourcesCheckBaseline"), + getDownloadSourcesSinceValue: () => + ipcRenderer.invoke("getDownloadSourcesSinceValue"), /* Library */ toggleAutomaticCloudSync: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 30b5c67a..b5d2492d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -214,6 +214,8 @@ declare global { ) => Promise; getDownloadSources: () => Promise; syncDownloadSources: () => Promise; + getDownloadSourcesCheckBaseline: () => Promise; + getDownloadSourcesSinceValue: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts index 34cc7bf7..dfc9b3b4 100644 --- a/src/renderer/src/hooks/use-download-options-listener.ts +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -8,9 +8,9 @@ export function useDownloadOptionsListener() { useEffect(() => { const unsubscribe = window.electron.onNewDownloadOptions( (gamesWithNewOptions) => { - gamesWithNewOptions.forEach(({ gameId, count }) => { + for (const { gameId, count } of gamesWithNewOptions) { dispatch(updateGameNewDownloadOptions({ gameId, count })); - }); + } } ); diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index ba9778fd..9bffc676 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -45,12 +45,27 @@ &__repack-title { color: globals.$muted-color; word-break: break-word; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); } &__repack-info { font-size: globals.$small-font-size; } + &__new-badge { + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + flex-shrink: 0; + } + &__no-results { width: 100%; padding: calc(globals.$spacing-unit * 4) 0; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 306e8647..1627d8e9 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,8 +15,7 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import type { DownloadSource } from "@types"; -import type { GameRepack } from "@types"; +import type { DownloadSource, GameRepack } from "@types"; import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; @@ -53,6 +52,10 @@ export function RepacksModal({ const [hashesInDebrid, setHashesInDebrid] = useState>( {} ); + const [lastCheckTimestamp, setLastCheckTimestamp] = useState( + null + ); + const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true); const { game, repacks } = useContext(gameDetailsContext); @@ -66,8 +69,8 @@ export function RepacksModal({ return null; } - const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i; - const match = magnet.match(hashRegex); + const hashRegex = /xt=urn:btih:([a-f0-9]+)/i; + const match = hashRegex.exec(magnet); return match ? match[1].toLowerCase() : null; }; @@ -97,6 +100,21 @@ export function RepacksModal({ fetchDownloadSources(); }, []); + useEffect(() => { + const fetchLastCheckTimestamp = async () => { + setIsLoadingTimestamp(true); + + const timestamp = await window.electron.getDownloadSourcesSinceValue(); + + setLastCheckTimestamp(timestamp); + setIsLoadingTimestamp(false); + }; + + if (visible) { + fetchLastCheckTimestamp(); + } + }, [visible, repacks]); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -158,6 +176,19 @@ export function RepacksModal({ return repack.uris.some((uri) => uri.includes(game.download!.uri)); }; + const isNewRepack = (repack: GameRepack): boolean => { + // Don't show badge while loading timestamp + if (isLoadingTimestamp) return false; + + if (!lastCheckTimestamp || !repack.createdAt) { + return false; + } + + const lastCheckUtc = new Date(lastCheckTimestamp).toISOString(); + + return repack.createdAt > lastCheckUtc; + }; + const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); useEffect(() => { @@ -273,7 +304,14 @@ export function RepacksModal({ onClick={() => handleRepackClick(repack)} className="repacks-modal__repack-button" > -

{repack.title}

+

+ {repack.title} + {isNewRepack(repack) && ( + + {t("new_download_option")} + + )} +

{isLastDownloadedOption && ( {t("last_downloaded_option")} diff --git a/src/types/index.ts b/src/types/index.ts index 4b13c496..f435bc60 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,6 +23,7 @@ export interface GameRepack { uploadDate: string | null; downloadSourceId: string; downloadSourceName: string; + createdAt: string; } export interface DownloadSource { From 5067cf163e5bf65122858ce1446a2edb57d15fa1 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 2 Nov 2025 18:22:37 +0200 Subject: [PATCH 04/36] feat: added new badge to repacks-modal, set up badge clearing --- src/main/events/index.ts | 1 + .../library/clear-new-download-options.ts | 27 ++++++++++++++++ src/main/services/download-sources-checker.ts | 13 -------- src/preload/index.ts | 2 ++ src/renderer/src/declaration.d.ts | 4 +++ .../game-details/modals/repacks-modal.scss | 10 +++--- .../game-details/modals/repacks-modal.tsx | 31 +++++++++++++++++-- 7 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 src/main/events/library/clear-new-download-options.ts diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 162b08d7..d75f8255 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -19,6 +19,7 @@ import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; import "./library/extract-game-download"; +import "./library/clear-new-download-options"; import "./library/open-game"; import "./library/open-game-executable-path"; import "./library/open-game-installer"; diff --git a/src/main/events/library/clear-new-download-options.ts b/src/main/events/library/clear-new-download-options.ts new file mode 100644 index 00000000..55ebfd8f --- /dev/null +++ b/src/main/events/library/clear-new-download-options.ts @@ -0,0 +1,27 @@ +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); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index f8b853a7..928e3d52 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -73,10 +73,6 @@ export class DownloadSourcesChecker { gameId: `${game.shop}:${game.objectId}`, count: gameUpdate.newDownloadOptionsCount, }); - - logger.info( - `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` - ); } } } @@ -121,7 +117,6 @@ export class DownloadSourcesChecker { return; } - // Get download sources const downloadSources = await downloadSourcesSublevel.values().all(); const downloadSourceIds = downloadSources.map((source) => source.id); logger.info( @@ -135,7 +130,6 @@ export class DownloadSourcesChecker { return; } - // Get when we LAST started the app (for this check's 'since' parameter) const previousBaseline = await getDownloadSourcesCheckBaseline(); const since = previousBaseline || @@ -143,10 +137,8 @@ export class DownloadSourcesChecker { logger.info(`Using since: ${since} (from last app start)`); - // Clear any previously stored new download option counts so badges don't persist across restarts const clearedPayload = await this.clearStaleBadges(nonCustomGames); - // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ shop: game.shop, objectId: game.objectId, @@ -164,7 +156,6 @@ export class DownloadSourcesChecker { } ); - // Call the API const response = await HydraApi.checkDownloadSourcesChanges( downloadSourceIds, games, @@ -173,24 +164,20 @@ export class DownloadSourcesChecker { logger.info("API call completed, response:", response); - // Save the 'since' value we just used (for modal to compare against) await updateDownloadSourcesSinceValue(since); logger.info(`Saved 'since' value: ${since} (for modal comparison)`); - // Update baseline to NOW (for next app start's 'since') const now = new Date().toISOString(); await updateDownloadSourcesCheckBaseline(now); logger.info( `Updated baseline to: ${now} (will be 'since' on next app start)` ); - // Process the response and store newDownloadOptionsCount for games with new options const gamesWithNewOptions = await this.processApiResponse( response, nonCustomGames ); - // Send IPC event to renderer to clear stale badges and set fresh counts from response this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); logger.info("Download sources check completed successfully"); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a879d70..69cbb3d4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -183,6 +183,8 @@ 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: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index b5d2492d..54b1be51 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -142,6 +142,10 @@ declare global { shop: GameShop, objectId: string ) => Promise; + clearNewDownloadOptions: ( + shop: GameShop, + objectId: string + ) => Promise; toggleGamePin: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index 9bffc676..3f78c82c 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -55,15 +55,15 @@ } &__new-badge { - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); - padding: 4px 8px; + background-color: rgba(34, 197, 94, 0.15); + color: rgb(187, 247, 208); + padding: 2px 6px; border-radius: 6px; - font-size: 12px; + font-size: 10px; font-weight: 600; - min-width: 24px; text-align: center; flex-shrink: 0; + border: 1px solid rgba(34, 197, 94, 0.5); } &__no-results { diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 1627d8e9..fa9b04a1 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -21,7 +21,8 @@ import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; import { Downloader } from "@shared"; import { orderBy } from "lodash-es"; -import { useDate, useFeature } from "@renderer/hooks"; +import { useDate, useFeature, useAppDispatch } from "@renderer/hooks"; +import { clearNewDownloadOptions } from "@renderer/features"; import "./repacks-modal.scss"; export interface RepacksModalProps { @@ -56,6 +57,9 @@ export function RepacksModal({ null ); const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true); + const [viewedRepackIds, setViewedRepackIds] = useState>( + new Set() + ); const { game, repacks } = useContext(gameDetailsContext); @@ -63,14 +67,15 @@ export function RepacksModal({ const { formatDate } = useDate(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); const getHashFromMagnet = (magnet: string) => { if (!magnet || typeof magnet !== "string") { return null; } - const hashRegex = /xt=urn:btih:([a-f0-9]+)/i; - const match = hashRegex.exec(magnet); + const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i; + const match = magnet.match(hashRegex); return match ? match[1].toLowerCase() : null; }; @@ -115,6 +120,21 @@ export function RepacksModal({ } }, [visible, repacks]); + useEffect(() => { + if ( + visible && + game?.newDownloadOptionsCount && + game.newDownloadOptionsCount > 0 + ) { + // Clear the badge in the database + globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); + + // Clear the badge in Redux store + const gameId = `${game.shop}:${game.objectId}`; + dispatch(clearNewDownloadOptions({ gameId })); + } + }, [visible, game, dispatch]); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -157,6 +177,8 @@ export function RepacksModal({ const handleRepackClick = (repack: GameRepack) => { setRepack(repack); setShowSelectFolderModal(true); + // Mark this repack as viewed to hide the "NEW" badge + setViewedRepackIds((prev) => new Set(prev).add(repack.id)); }; const handleFilter: React.ChangeEventHandler = (event) => { @@ -180,6 +202,9 @@ export function RepacksModal({ // Don't show badge while loading timestamp if (isLoadingTimestamp) return false; + // Don't show badge if user has already clicked this repack in current session + if (viewedRepackIds.has(repack.id)) return false; + if (!lastCheckTimestamp || !repack.createdAt) { return false; } From 87d35da9fcbbed07dc155397f5737ae4bc79d475 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 2 Nov 2025 18:24:10 +0200 Subject: [PATCH 05/36] fix: deleted comments --- src/renderer/src/pages/game-details/modals/repacks-modal.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index fa9b04a1..91013da0 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -126,10 +126,8 @@ export function RepacksModal({ game?.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 ) { - // Clear the badge in the database globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); - // Clear the badge in Redux store const gameId = `${game.shop}:${game.objectId}`; dispatch(clearNewDownloadOptions({ gameId })); } @@ -177,7 +175,6 @@ export function RepacksModal({ const handleRepackClick = (repack: GameRepack) => { setRepack(repack); setShowSelectFolderModal(true); - // Mark this repack as viewed to hide the "NEW" badge setViewedRepackIds((prev) => new Set(prev).add(repack.id)); }; @@ -199,10 +196,8 @@ export function RepacksModal({ }; const isNewRepack = (repack: GameRepack): boolean => { - // Don't show badge while loading timestamp if (isLoadingTimestamp) return false; - // Don't show badge if user has already clicked this repack in current session if (viewedRepackIds.has(repack.id)) return false; if (!lastCheckTimestamp || !repack.createdAt) { From 6f6b7d49ac120fa2bed6055f8e8f3a45a551e325 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 2 Nov 2025 18:47:26 +0200 Subject: [PATCH 06/36] fix: removed void and converted conditional to boolean --- src/main/main.ts | 2 +- src/renderer/src/components/sidebar/sidebar-game-item.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 50173390..6fc2b216 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -59,7 +59,7 @@ export const loadState = async () => { void syncDownloadSourcesFromApi(); // Check for new download options on startup - void DownloadSourcesChecker.checkForChanges(); + DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); }); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 59698935..ee16d418 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -81,7 +81,7 @@ export function SidebarGameItem({ {getGameTitle(game)} - {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && ( + {(game.newDownloadOptionsCount ?? 0) > 0 && (
+
From aa6b595b18feef3797d2d6ba576b19125a099ba9 Mon Sep 17 00:00:00 2001 From: expload233 Date: Mon, 3 Nov 2025 18:53:02 +0800 Subject: [PATCH 07/36] Fill in the missing entries --- .../translation.backup.20251103_185046.json | 549 ++++++++++++++++++ src/locales/zh/translation.json | 193 +++++- 2 files changed, 736 insertions(+), 6 deletions(-) create mode 100644 src/locales/zh/translation.backup.20251103_185046.json diff --git a/src/locales/zh/translation.backup.20251103_185046.json b/src/locales/zh/translation.backup.20251103_185046.json new file mode 100644 index 00000000..7cdd0c92 --- /dev/null +++ b/src/locales/zh/translation.backup.20251103_185046.json @@ -0,0 +1,549 @@ +{ + "language_name": "简体中文", + "app": { + "successfully_signed_in": "已成功登录" + }, + "home": { + "surprise_me": "向我推荐", + "no_results": "没有找到结果", + "start_typing": "键入以开始搜素...", + "hot": "当下热门", + "weekly": "📅本周热门游戏", + "achievements": "🏆尝试击败" + }, + "sidebar": { + "catalogue": "游戏目录", + "downloads": "下载中心", + "settings": "设置", + "my_library": "我的游戏库", + "downloading_metadata": "{{title}} (正在下载元数据…)", + "paused": "{{title}} (已暂停)", + "downloading": "{{title}} ({{percentage}} - 正在下载…)", + "filter": "筛选游戏库", + "home": "主页", + "queued": "{{title}} (已加入下载队列)", + "game_has_no_executable": "未选择游戏的可执行文件", + "sign_in": "登入", + "friends": "好友", + "favorites": "收藏", + "need_help": "需要帮助?", + "playable_button_title": "仅显示现在可以游玩的游戏" + }, + "header": { + "search": "搜索游戏", + "home": "主页", + "catalogue": "游戏目录", + "downloads": "下载中心", + "search_results": "搜索结果", + "settings": "设置", + "version_available_install": "版本 {{version}} 已可用. 点击此处重新启动并安装.", + "version_available_download": "版本 {{version}} 可用. 点击此处下载." + }, + "bottom_panel": { + "no_downloads_in_progress": "没有正在进行的下载", + "downloading_metadata": "正在下载{{title}}的元数据…", + "downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}", + "calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间...", + "checking_files": "正在校验 {{title}} 的文件... ({{percentage}} 已完成)", + "installation_complete": "安装完成", + "installation_complete_message": "通用可再发行组件安装成功", + "installing_common_redist": "{{log}}…" + }, + "catalogue": { + "clear_filters": "清除已选的 {{filterCount}} 项", + "developers": "开发商", + "download_sources": "下载源", + "filter_count": "{{filterCount}} 项可用", + "genres": "类型", + "publishers": "发行商", + "result_count": "{{resultCount}} 个结果", + "search": "筛选…", + "tags": "标签" + }, + "game_details": { + "open_download_options": "打开下载菜单", + "download_options_zero": "无可下载项", + "download_options_one": "{{count}}个可下载项", + "download_options_other": "{{count}}个可下载项", + "updated_at": "更新于{{updated_at}}", + "install": "安装", + "resume": "恢复", + "pause": "暂停", + "cancel": "取消", + "remove": "移除", + "space_left_on_disk": "磁盘剩余空间{{space}}", + "eta": "预计完成时间{{eta}}", + "calculating_eta": "正在计算剩余时间…", + "downloading_metadata": "正在下载元数据…", + "filter": "筛选重打包", + "requirements": "配置要求", + "minimum": "最低要求", + "recommended": "推荐要求", + "paused": "已暂停", + "release_date": "发布于{{date}}", + "publisher": "发行商{{publisher}}", + "hours": "小时", + "minutes": "分钟", + "amount_hours": "{{amount}}小时", + "amount_minutes": "{{amount}}分钟", + "accuracy": "准确度{{accuracy}}%", + "add_to_library": "添加到游戏库", + "remove_from_library": "从游戏库移除", + "no_downloads": "没有可用的下载", + "play_time": "游戏时长{{amount}}", + "last_time_played": "上次玩{{period}}", + "not_played_yet": "您还没有玩过{{title}}", + "next_suggestion": "下一个建议", + "play": "开始游戏", + "deleting": "正在删除安装程序…", + "close": "关闭", + "playing_now": "正在游戏中", + "change": "更改", + "repacks_modal_description": "选择您想要下载的重打包", + "select_folder_hint": "要更改默认文件夹,请访问<0>设置", + "download_now": "立即下载", + "no_shop_details": "无法检索商店详细信息.", + "download_options": "下载选项", + "download_path": "下载路径", + "previous_screenshot": "上一张截图", + "next_screenshot": "下一张截图", + "screenshot": "截图 {{number}}", + "open_screenshot": "打开截图 {{number}}", + "download_settings": "下载设置", + "downloader": "下载器", + "select_executable": "选择可执行文件", + "no_executable_selected": "没有可执行文件被指定", + "open_folder": "打开目录", + "open_download_location": "查看已下载的文件", + "create_shortcut": "创建桌面快捷方式", + "remove_files": "删除文件", + "remove_from_library_title": "你确定吗?", + "remove_from_library_description": "这将会把 {{game}} 从你的库中移除", + "options": "选项", + "executable_section_title": "可执行文件", + "executable_section_description": "点击 \"Play\" 时将执行的文件的路径", + "downloads_section_title": "下载", + "downloads_section_description": "查看此游戏的更新或其他版本", + "danger_zone_section_title": "危险操作", + "danger_zone_section_description": "从您的库或Hydra下载的文件中删除此游戏", + "download_in_progress": "下载进行中", + "download_paused": "下载暂停", + "last_downloaded_option": "上次下载的选项", + "create_shortcut_success": "成功创建快捷方式", + "create_shortcut_error": "创建快捷方式出错", + "nsfw_content_title": "本游戏包含不适合展示的内容", + "nsfw_content_description": "{{title}}包含可能不适合所有年龄段的内容。您确定要继续吗?", + "allow_nsfw_content": "继续", + "refuse_nsfw_content": "返回", + "stats": "统计数据", + "download_count": "下载量", + "player_count": "活跃玩家", + "download_error": "此下载选项不可用", + "download": "下载", + "executable_path_in_use": "可执行文件已经被以下游戏 \"{{game}}\" 使用", + "warning": "警告:", + "hydra_needs_to_remain_open": "对于此次下载,Hydra必须保持开启直至其完成。若海德拉在完成前关闭,您的进度将丢失。", + "achievements": "成就", + "achievements_count": "成就 {{unlockedCount}}/{{achievementsCount}}", + "cloud_save": "云存档", + "cloud_save_description": "将您的进度保存在云端,便可在任何设备上继续游戏。", + "backups": "备份", + "install_backup": "安装", + "delete_backup": "删除", + "create_backup": "新备份", + "last_backup_date": "最后一次备份于{{date}}", + "no_backup_preview": "未找到此游戏标题的存档", + "restoring_backup": "正在恢复备份({{progress}}已完成)…", + "uploading_backup": "上传备份中…", + "no_backups": "您尚未为这款游戏创建任何备份", + "backup_uploaded": "备份已上传", + "backup_deleted": "备份已删除", + "backup_restored": "备份已恢复", + "see_all_achievements": "查看所有成就", + "sign_in_to_see_achievements": "登入以查看所有成就", + "mapping_method_automatic": "自动", + "mapping_method_manual": "常规", + "mapping_method_label": "索引类型", + "files_automatically_mapped": "文件已自动索引", + "no_backups_created": "没有为此游戏创建过备份", + "manage_files": "管理文件", + "loading_save_preview": "正在查找要保存的游戏…", + "wine_prefix": "Wine 前置", + "wine_prefix_description": "运行该游戏所用的 Wine 前置", + "no_download_option_info": "无可用信息", + "backup_deletion_failed": "删除备份失败", + "max_number_of_artifacts_reached": "已达到该游戏备份上限", + "achievements_not_sync": "你的成就未同步", + "manage_files_description": "管理哪些文件要备份和恢复", + "select_folder": "选择文件夹", + "backup_from": "{{date}} 时备份", + "custom_backup_location_set": "自定义备份文件位置", + "artifact_name_label": "备份名称", + "artifact_name_placeholder": "为备份输入名称", + "artifact_renamed": "备份重命名成功", + "automatic_backup_from": "{{date}} 的自动备份", + "automatically_extract_downloaded_files": "自动解压下载的文件", + "backup_freeze_failed": "固定备份失败", + "backup_freeze_failed_description": "您必须至少保留一个空位用于自动备份", + "backup_frozen": "备份已固定", + "backup_unfrozen": "备份已取消固定", + "clear": "清除", + "create_start_menu_shortcut": "创建开始菜单快捷方式", + "create_steam_shortcut": "创建Steam快捷方式", + "download_error_gofile_quota_exceeded": "您已超出Gofile的月度配额。请等待配额重置。", + "download_error_not_cached_on_hydra": "此下载在Nimbus上不可用。", + "download_error_not_cached_on_real_debrid": "此下载在Real-Debrid上不可用,且暂不支持从Real-Debrid轮询下载状态。", + "download_error_not_cached_on_torbox": "此下载在TorBox上不可用,且暂不支持从TorBox轮询下载状态。", + "download_error_real_debrid_account_not_authorized": "您的Real-Debrid账户未被授权进行新下载。请检查您的账户设置并重试。", + "enable_automatic_cloud_sync": "启用自动云同步", + "freeze_backup": "固定以免被自动备份覆盖", + "game_added_to_favorites": "游戏已添加到收藏", + "game_removed_from_favorites": "游戏已从收藏中移除", + "invalid_wine_prefix_path": "无效的Wine前置路径", + "invalid_wine_prefix_path_description": "Wine前置的路径无效。请检查路径并重试。", + "launch_options": "启动选项", + "launch_options_description": "高级用户可以选择修改启动选项(实验性功能)", + "launch_options_placeholder": "未指定参数", + "max_length_field": "此字段必须少于 {{length}} 个字符", + "missing_wine_prefix": "在Linux上创建备份需要Wine前置", + "no_directory_selected": "未选择目录", + "no_write_permission": "无法下载到此目录。点击此处了解更多。", + "rename_artifact": "重命名备份", + "rename_artifact_description": "将备份重命名为更具描述性的名称", + "required_field": "此字段为必填项", + "reset_achievements": "重置成就", + "reset_achievements_description": "这将重置 {{game}} 的所有成就", + "reset_achievements_error": "重置成就失败", + "reset_achievements_success": "成就重置成功", + "reset_achievements_title": "您确定吗?", + "save_changes": "保存更改", + "unfreeze_backup": "取消固定", + "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改" + }, + "activation": { + "title": "激活 Hydra", + "installation_id": "安装ID:", + "enter_activation_code": "输入您的激活码", + "message": "如果你不知道在哪里请求这个,那么您将无法继续。", + "activate": "激活", + "loading": "加载中…" + }, + "downloads": { + "resume": "继续", + "pause": "暂停", + "eta": "预计完成时间{{eta}}", + "paused": "已暂停", + "verifying": "正在验证…", + "completed": "已完成", + "removed": "未下载", + "cancel": "取消", + "filter": "筛选已下载游戏", + "remove": "移除", + "downloading_metadata": "正在下载元数据…", + "deleting": "正在删除安装程序…", + "delete": "移除安装程序", + "delete_modal_title": "您确定吗?", + "delete_modal_description": "这将从您的电脑上移除所有的安装文件", + "install": "安装", + "download_in_progress": "进行中", + "queued_downloads": "在队列中的下载", + "downloads_completed": "已完成", + "queued": "下载列表", + "no_downloads_title": "空空如也", + "no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。", + "checking_files": "正在校验文件…", + "extract": "解压文件", + "extracting": "正在解压文件…", + "options": "管理", + "resume_seeding": "恢复做种", + "seeding": "做种中", + "stop_seeding": "停止做种" + }, + "settings": { + "downloads_path": "下载路径", + "change": "更改", + "notifications": "通知", + "enable_download_notifications": "下载完成时", + "enable_repack_list_notifications": "添加新重打包时", + "real_debrid_api_token_label": "Real-Debrid API 令牌", + "quit_app_instead_hiding": "关闭Hydra而不是最小化到托盘", + "launch_with_system": "系统启动时运行 Hydra", + "general": "通用", + "behavior": "行为", + "download_sources": "下载源", + "language": "语言", + "api_token": "API 令牌", + "enable_real_debrid": "启用 Real-Debrid", + "real_debrid_description": "Real-Debrid 是一个无限制的下载器,允许您以最快的互联网速度即时下载文件。", + "debrid_invalid_token": "无效的 API 令牌", + "debrid_api_token_hint": "您可以从<0>这里获取API密钥.", + "real_debrid_free_account_error": "账户 \"{{username}}\" 是免费账户。请订阅 Real-Debrid", + "debrid_linked_message": "账户 \"{{username}}\" 已链接", + "save_changes": "保存更改", + "changes_saved": "更改已成功保存", + "download_sources_description": "Hydra 将从这些源获取下载链接。源 URL 必须是直接链接到包含下载链接的 .json 文件。", + "validate_download_source": "验证", + "remove_download_source": "移除", + "cancel_button_confirmation_delete_all_sources": "不", + "confirm_button_confirmation_delete_all_sources": "是的,删除所有内容", + "description_confirmation_delete_all_sources": "您将删除所有下载源", + "title_confirmation_delete_all_sources": "删除所有下载源", + "removed_download_sources": "已删除字体", + "button_delete_all_sources": "删除所有下载源", + "add_download_source": "添加源", + "download_count_zero": "列表中无下载", + "download_count_one": "列表中有 {{countFormatted}} 个下载", + "download_count_other": "列表中有 {{countFormatted}} 个下载", + "download_source_url": "下载源 URL", + "add_download_source_description": "插入包含 .json 文件的 URL", + "download_source_up_to_date": "已更新", + "download_source_errored": "出错", + "sync_download_sources": "同步源", + "removed_download_source": "已移除下载源", + "added_download_source": "已添加下载源", + "download_sources_synced": "所有下载源已同步", + "insert_valid_json_url": "插入有效的 JSON 网址", + "found_download_option_zero": "未找到下载选项", + "found_download_option_one": "找到 {{countFormatted}} 个下载选项", + "found_download_option_other": "找到 {{countFormatted}} 个下载选项", + "import": "导入", + "public": "公开", + "private": "私密", + "friends_only": "仅限朋友", + "privacy": "隐私", + "profile_visibility": "资料可见性", + "profile_visibility_description": "选择谁可以查看您的个人资料和资料库", + "required_field": "该字段为必填字段", + "source_already_exists": "已添加此来源", + "must_be_valid_url": "来源必须是有效的 URL", + "blocked_users": "已屏蔽用户", + "user_unblocked": "用户已经被屏蔽", + "enable_achievement_notifications": "当成就解锁时", + "account": "账户", + "account_data_updated_successfully": "账户数据更新成功", + "achievement_custom_notification_position": "成就自定义通知位置", + "alignment": "对齐", + "appearance": "外观", + "become_subscriber": "成为Hydra Cloud用户", + "bill_sent_until": "您的下一张账单将在此日期前发送", + "bottom-center": "底部中央", + "bottom-left": "底部左侧", + "bottom-right": "底部右侧", + "cancel": "取消", + "clear_themes": "清除", + "common_redist": "通用可再发行组件", + "common_redist_description": "运行某些游戏需要通用可再发行组件。建议安装以避免问题。", + "create_real_debrid_account": "如果您还没有Real-Debrid账户,请点击此处", + "create_theme": "创建", + "create_theme_modal_description": "创建新主题以自定义Hydra的外观", + "create_theme_modal_title": "创建自定义主题", + "create_torbox_account": "如果您还没有TorBox账户,请点击此处", + "current_email": "当前邮箱:", + "default": "默认", + "delete_all_themes": "删除所有主题", + "delete_all_themes_description": "这将删除所有您的自定义主题", + "delete_theme": "删除主题", + "delete_theme_description": "这将删除主题 {{theme}}", + "disable_nsfw_alert": "禁用NSFW警告", + "edit_theme": "编辑主题", + "editor_tab_code": "代码", + "editor_tab_info": "信息", + "editor_tab_save": "保存", + "enable_achievement_custom_notifications": "启用成就自定义通知", + "enable_auto_install": "自动下载更新", + "enable_friend_request_notifications": "当收到好友请求时", + "enable_friend_start_game_notifications": "当好友开始游戏时", + "enable_torbox": "启用TorBox", + "error_importing_theme": "导入主题时出错", + "extract_files_by_default": "下载后默认解压文件", + "hidden": "隐藏", + "import_theme": "导入主题", + "import_theme_description": "您将从主题商店导入 {{theme}}", + "insert_theme_name": "输入主题名称", + "install_common_redist": "安装", + "installing_common_redist": "正在安装…", + "launch_minimized": "最小化启动Hydra", + "manage_subscription": "管理订阅", + "name_min_length": "主题名称必须至少3个字符长", + "no_email_account": "您尚未设置邮箱", + "no_subscription": "以最佳方式享受Hydra", + "no_themes": "看起来您还没有任何主题,但别担心,点击这里创建您的第一个杰作。", + "no_users_blocked": "您没有屏蔽任何用户", + "notification_preview": "成就通知预览", + "platinum": "白金", + "rare": "稀有", + "real_debrid_account_linked": "Real-Debrid账户已连接", + "renew_subscription": "续费Hydra Cloud", + "seed_after_download_complete": "下载完成后做种", + "set_theme": "设置主题", + "show_download_speed_in_megabytes": "以兆字节每秒显示下载速度", + "show_hidden_achievement_description": "在解锁前显示隐藏成就描述", + "subscription_active_until": "您的Hydra Cloud活跃至 {{date}}", + "subscription_expired_at": "您的订阅已于 {{date}} 到期", + "subscription_renew_cancelled": "自动续费已禁用", + "subscription_renews_on": "您的订阅将于 {{date}} 续费", + "test_notification": "测试通知", + "theme_imported": "主题导入成功", + "theme_name": "名称", + "top-center": "顶部中央", + "top-left": "顶部左侧", + "top-right": "顶部右侧", + "torbox_account_linked": "TorBox账户已连接", + "torbox_description": "TorBox是您的高级种子盒服务,甚至可与市场上最好的服务器相媲美。", + "unset_theme": "取消设置主题", + "update_email": "更新邮箱", + "update_password": "更新密码", + "variation": "变体", + "web_store": "网络商店" + }, + "notifications": { + "download_complete": "下载完成", + "game_ready_to_install": "{{title}} 已准备就绪", + "repack_list_updated": "重打包列表已更新", + "repack_count_one": "{{count}} 重打包已添加", + "repack_count_other": "{{count}} 重打包已添加", + "new_update_available": "版本 {{version}} 可用", + "restart_to_install_update": "重启 Hydra 以安装更新", + "notification_achievement_unlocked_title": "{{game}} 的成绩已解锁", + "notification_achievement_unlocked_body": "{{achievement}} 和其他 {{count}} 已解锁", + "extraction_complete": "解压完成", + "friend_started_playing_game": "{{displayName}} 开始玩游戏", + "game_extracted": "{{title}} 解压成功", + "new_friend_request_description": "{{displayName}} 向您发送了好友请求", + "new_friend_request_title": "新好友请求", + "test_achievement_notification_description": "非常酷,对吧?", + "test_achievement_notification_title": "这是一个测试通知" + }, + "system_tray": { + "open": "打开 Hydra", + "quit": "退出" + }, + "game_card": { + "no_downloads": "无可用下载选项", + "available_one": "可用", + "available_other": "可用" + }, + "binary_not_found_modal": { + "title": "程序未安装", + "description": "您的系统中找不到 Wine 或 Lutris 的可执行文件", + "instructions": "请检查在 Linux 发行版上安装这些软件的正确方法,以便游戏能够正常运行" + }, + "forms": { + "toggle_password_visibility": "切换密码可见性" + }, + "modal": { + "close": "关闭按钮" + }, + "user_profile": { + "amount_hours": "{{amount}} 小时", + "amount_minutes": "{{amount}} 分钟", + "last_time_played": "上次游玩时间 {{period}}", + "activity": "近期活动", + "library": "库", + "total_play_time": "总游戏时长", + "no_recent_activity_title": "Emmm… 这里暂时啥都没有", + "no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!", + "display_name": "昵称", + "saving": "保存中", + "save": "保存", + "edit_profile": "编辑资料", + "saved_successfully": "成功保存", + "try_again": "请重试", + "sign_out_modal_title": "你确定吗?", + "cancel": "取消", + "successfully_signed_out": "登出成功", + "sign_out": "登出", + "playing_for": "已经玩了{{amount}}", + "sign_out_modal_text": "您的资料库与您当前的账户相关联。注销后,您的资料库将不再可见,任何进度也不会保存。继续退出吗?", + "add_friends": "添加好友", + "add": "添加", + "friend_code": "好友代码", + "see_profile": "查看资料", + "sending": "发送中", + "friend_request_sent": "好友请求已发送", + "friends": "好友", + "friends_list": "好友列表", + "user_not_found": "未找到此用户", + "block_user": "屏蔽此用户", + "add_friend": "添加好友", + "request_sent": "请求已发送", + "request_received": "已收到请求", + "accept_request": "同意申请", + "ignore_request": "忽略申请", + "cancel_request": "取消申请", + "undo_friendship": "解除好友关系", + "request_accepted": "请求已通过", + "user_blocked_successfully": "成功屏蔽此用户", + "user_block_modal_text": "这将会屏蔽 {{displayName}}", + "blocked_users": "黑名单用户", + "unblock": "解除屏蔽", + "no_friends_added": "你还没有添加过好友", + "pending": "待处理", + "no_pending_invites": "您没有待处理的邀请", + "no_blocked_users": "你没有已经拉人黑名单的用户", + "friend_code_copied": "好友代码已复制", + "undo_friendship_modal_text": "这将使你与 {{displayName}} 解除好友关系", + "privacy_hint": "要调整谁可以看到你的个人资料,可以去<0>设置中修改", + "locked_profile": "此个人资料是私密的", + "image_process_failure": "处理图片时发生错误", + "required_field": "此字段为必填项", + "displayname_min_length": "显示名称最少必须为3个字符。", + "displayname_max_length": "显示名称最多必须为50个字符", + "report_profile": "举报此资料", + "report_reason": "为什么你要举报此资料?", + "report_description": "额外信息", + "report_description_placeholder": "额外信息", + "report": "举报", + "report_reason_hate": "仇恨言论", + "report_reason_sexual_content": "色情内容", + "report_reason_violence": "暴力", + "report_reason_spam": "骚扰", + "report_reason_other": "其他", + "profile_reported": "个人资料已举报", + "your_friend_code": "你的好友代码:", + "upload_banner": "上传横幅", + "uploading_banner": "上传横幅中…", + "background_image_updated": "背景图片已更新", + "achievements": "成就", + "achievements_unlocked": "成就已解锁", + "earned_points": "获得积分", + "error_adding_friend": "无法发送好友请求。请检查好友代码", + "friend_code_length_error": "好友代码必须为8个字符", + "games": "游戏", + "playing": "正在玩 {{game}}", + "ranking_updated_weekly": "排名每周更新", + "show_achievements_on_profile": "在您的个人资料上显示成就", + "show_points_on_profile": "在您的个人资料上显示获得的积分", + "stats": "统计", + "top_percentile": "前 {{percentile}}%" + }, + "achievement": { + "achievement_unlocked": "成就已解锁", + "user_achievements": "{{displayName}}的成就", + "your_achievements": "你的成就", + "unlocked_at": "解锁于: {{date}}", + "subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容", + "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就", + "achievement_earn_points": "通过此成就获得 {{points}} 积分", + "achievement_progress": "{{unlockedCount}}/{{totalCount}} 成就", + "achievements_unlocked_for_game": "为 {{gameTitle}} 解锁了 {{achievementCount}} 个新成就", + "available_points": "可用积分:", + "earned_points": "获得积分:", + "hidden_achievement_tooltip": "这是一个隐藏成就", + "how_to_earn_achievements_points": "如何获得成就积分?" + }, + "hydra_cloud": { + "subscription_tour_title": "Hydra 云订阅", + "subscribe_now": "现在订购", + "cloud_saving": "云存档", + "cloud_achievements": "将你的成就保存至云端", + "animated_profile_picture": "动画头像", + "premium_support": "高级技术支持", + "show_and_compare_achievements": "展示并与其他用户比较您的成就", + "animated_profile_banner": "动态个人简介横幅", + "debrid_description": "使用Nimbus下载速度提升4倍", + "hydra_cloud": "Hydra Cloud", + "hydra_cloud_feature_found": "您刚刚发现了一个Hydra Cloud功能!", + "learn_more": "了解更多" + } +} diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 7cdd0c92..6120b081 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -27,7 +27,68 @@ "friends": "好友", "favorites": "收藏", "need_help": "需要帮助?", - "playable_button_title": "仅显示现在可以游玩的游戏" + "playable_button_title": "仅显示现在可以游玩的游戏", + "add_custom_game_tooltip": "[待翻译]Add Custom Game", + "cancel": "[待翻译]Cancel", + "confirm": "[待翻译]Confirm", + "custom_game_modal": "[待翻译]Add Custom Game", + "custom_game_modal_add": "[待翻译]Add Game", + "custom_game_modal_adding": "[待翻译]Adding Game...", + "custom_game_modal_browse": "[待翻译]Browse", + "custom_game_modal_cancel": "[待翻译]Cancel", + "custom_game_modal_description": "[待翻译]Add a custom game to your library by selecting an executable file", + "custom_game_modal_enter_title": "[待翻译]Enter title", + "custom_game_modal_executable": "[待翻译]Executable", + "custom_game_modal_executable_path": "[待翻译]Executable Path", + "custom_game_modal_failed": "[待翻译]Failed to add custom game", + "custom_game_modal_select_executable": "[待翻译]Select executable file", + "custom_game_modal_success": "[待翻译]Custom game added successfully", + "custom_game_modal_title": "[待翻译]Title", + "decky_plugin_installation_error": "[待翻译]Error installing Decky plugin: {{error}}", + "decky_plugin_installation_failed": "[待翻译]Failed to install Decky plugin: {{error}}", + "decky_plugin_installed": "[待翻译]Decky plugin v{{version}} installed successfully", + "decky_plugin_installed_version": "[待翻译]Decky Plugin (v{{version}})", + "edit_game_modal": "[待翻译]Customize Assets", + "edit_game_modal_assets": "[待翻译]Assets", + "edit_game_modal_browse": "[待翻译]Browse", + "edit_game_modal_cancel": "[待翻译]Cancel", + "edit_game_modal_description": "[待翻译]Customize game assets and details", + "edit_game_modal_drop_hero_image_here": "[待翻译]Drop hero image here", + "edit_game_modal_drop_icon_image_here": "[待翻译]Drop icon image here", + "edit_game_modal_drop_logo_image_here": "[待翻译]Drop logo image here", + "edit_game_modal_drop_to_replace_hero": "[待翻译]Drop to replace hero", + "edit_game_modal_drop_to_replace_icon": "[待翻译]Drop to replace icon", + "edit_game_modal_drop_to_replace_logo": "[待翻译]Drop to replace logo", + "edit_game_modal_enter_title": "[待翻译]Enter title", + "edit_game_modal_failed": "[待翻译]Failed to update assets", + "edit_game_modal_fill_required": "[待翻译]Please fill in all required fields", + "edit_game_modal_hero": "[待翻译]Library Hero", + "edit_game_modal_hero_preview": "[待翻译]Library hero image preview", + "edit_game_modal_hero_resolution": "[待翻译]Recommended resolution: 1920x620px", + "edit_game_modal_icon": "[待翻译]Icon", + "edit_game_modal_icon_preview": "[待翻译]Icon preview", + "edit_game_modal_icon_resolution": "[待翻译]Recommended resolution: 256x256px", + "edit_game_modal_image": "[待翻译]Image", + "edit_game_modal_image_filter": "[待翻译]Image", + "edit_game_modal_image_preview": "[待翻译]Image preview", + "edit_game_modal_logo": "[待翻译]Logo", + "edit_game_modal_logo_preview": "[待翻译]Logo preview", + "edit_game_modal_logo_resolution": "[待翻译]Recommended resolution: 640x360px", + "edit_game_modal_select_hero": "[待翻译]Select library hero image", + "edit_game_modal_select_icon": "[待翻译]Select icon", + "edit_game_modal_select_image": "[待翻译]Select image", + "edit_game_modal_select_logo": "[待翻译]Select logo", + "edit_game_modal_success": "[待翻译]Assets updated successfully", + "edit_game_modal_title": "[待翻译]Title", + "edit_game_modal_update": "[待翻译]Update", + "edit_game_modal_updating": "[待翻译]Updating...", + "install_decky_plugin": "[待翻译]Install Decky Plugin", + "install_decky_plugin_message": "[待翻译]This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?", + "install_decky_plugin_title": "[待翻译]Install Hydra Decky Plugin", + "show_playable_only_tooltip": "[待翻译]Show Playable Only", + "update_decky_plugin": "[待翻译]Update Decky Plugin", + "update_decky_plugin_message": "[待翻译]A new version of the Hydra Decky plugin is available. Would you like to update it now?", + "update_decky_plugin_title": "[待翻译]Update Hydra Decky Plugin" }, "header": { "search": "搜索游戏", @@ -218,7 +279,93 @@ "reset_achievements_title": "您确定吗?", "save_changes": "保存更改", "unfreeze_backup": "取消固定", - "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改" + "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改", + "add_to_favorites": "[待翻译]Add to favorites", + "already_in_library": "[待翻译]Already in library", + "audio": "[待翻译]Audio", + "backup_failed": "[待翻译]Backup failed", + "be_first_to_review": "[待翻译]Be the first to share your thoughts about this game!", + "caption": "[待翻译]Caption", + "create_shortcut_simple": "[待翻译]Create shortcut", + "currency_country": "[待翻译]us", + "currency_symbol": "[待翻译]$", + "delete_review": "[待翻译]Delete review", + "delete_review_modal_cancel_button": "[待翻译]Cancel", + "delete_review_modal_delete_button": "[待翻译]Delete", + "delete_review_modal_description": "[待翻译]This action cannot be undone.", + "delete_review_modal_title": "[待翻译]Are you sure you want to delete your review?", + "edit_game_modal_button": "[待翻译]Customize game assets", + "failed_remove_files": "[待翻译]Failed to remove files", + "failed_remove_from_library": "[待翻译]Failed to remove from library", + "failed_update_favorites": "[待翻译]Failed to update favorites", + "files_removed_success": "[待翻译]Files removed successfully", + "filter_by_source": "[待翻译]Filter by source", + "game_added_to_pinned": "[待翻译]Game added to pinned", + "game_details": "[待翻译]Game Details", + "game_removed_from_library": "[待翻译]Game removed from library", + "game_removed_from_pinned": "[待翻译]Game removed from pinned", + "hide": "[待翻译]Hide", + "hide_original": "[待翻译]Hide original", + "historical_keyshop": "[待翻译]Historical keyshop", + "historical_retail": "[待翻译]Historical retail", + "keyshop_price": "[待翻译]Keyshop price", + "language": "[待翻译]Language", + "leave_a_review": "[待翻译]Leave a Review", + "load_more_reviews": "[待翻译]Load more reviews", + "loading_more_reviews": "[待翻译]Loading more reviews...", + "loading_reviews": "[待翻译]Loading reviews...", + "manual_playtime_tooltip": "[待翻译]This playtime has been manually updated", + "manual_playtime_warning": "[待翻译]Your hours will be marked as manually updated, and this cannot be undone.", + "maybe_later": "[待翻译]Maybe later", + "no_prices_found": "[待翻译]No prices found", + "no_repacks_found": "[待翻译]No sources found for this game", + "no_reviews_yet": "[待翻译]No reviews yet", + "prices": "[待翻译]Prices", + "properties": "[待翻译]Properties", + "rating": "[待翻译]Rating", + "rating_count": "[待翻译]Rating", + "rating_negative": "[待翻译]Negative", + "rating_neutral": "[待翻译]Neutral", + "rating_positive": "[待翻译]Positive", + "rating_stats": "[待翻译]Rating", + "rating_very_negative": "[待翻译]Very Negative", + "rating_very_positive": "[待翻译]Very Positive", + "remove_from_favorites": "[待翻译]Remove from favorites", + "remove_review": "[待翻译]Remove Review", + "retail_price": "[待翻译]Retail price", + "review_cannot_be_empty": "[待翻译]Review text field cannot be empty.", + "review_deleted_successfully": "[待翻译]Review deleted successfully.", + "review_deletion_failed": "[待翻译]Failed to delete review. Please try again.", + "review_from_blocked_user": "[待翻译]Review from blocked user", + "review_played_for": "[待翻译]Played for", + "review_submission_failed": "[待翻译]Failed to submit review. Please try again.", + "review_submitted_successfully": "[待翻译]Review submitted successfully!", + "reviews": "[待翻译]Reviews", + "show": "[待翻译]Show", + "show_less": "[待翻译]Show less", + "show_more": "[待翻译]Show more", + "show_original": "[待翻译]Show original", + "show_original_translated_from": "[待翻译]Show original (translated from {{language}})", + "show_translation": "[待翻译]Show translation", + "sort_highest_score": "[待翻译]Highest Score", + "sort_lowest_score": "[待翻译]Lowest Score", + "sort_most_voted": "[待翻译]Most Voted", + "sort_newest": "[待翻译]Newest", + "sort_oldest": "[待翻译]Oldest", + "submit_review": "[待翻译]Submit", + "submitting": "[待翻译]Submitting...", + "update_game_playtime": "[待翻译]Update game playtime", + "update_playtime": "[待翻译]Update playtime", + "update_playtime_description": "[待翻译]Manually update the playtime for {{game}}", + "update_playtime_error": "[待翻译]Failed to update playtime", + "update_playtime_success": "[待翻译]Playtime updated successfully", + "update_playtime_title": "[待翻译]Update playtime", + "view_all_prices": "[待翻译]Click to view all prices", + "vote_failed": "[待翻译]Failed to register your vote. Please try again.", + "would_you_recommend_this_game": "[待翻译]Would you like to leave a review to this game?", + "write_review_placeholder": "[待翻译]Share your thoughts about this game...", + "yes": "[待翻译]Yes", + "you_seemed_to_enjoy_this_game": "[待翻译]You've seemed to enjoy this game" }, "activation": { "title": "激活 Hydra", @@ -394,7 +541,24 @@ "update_email": "更新邮箱", "update_password": "更新密码", "variation": "变体", - "web_store": "网络商店" + "web_store": "网络商店", + "adding": "[待翻译]Adding…", + "autoplay_trailers_on_game_page": "[待翻译]Automatically start playing trailers on game page", + "debrid": "[待翻译]Debrid", + "debrid_description": "[待翻译]Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.", + "download_source_already_exists": "[待翻译]This download source URL already exists.", + "download_source_failed": "[待翻译]Error", + "download_source_matched": "[待翻译]Up to date", + "download_source_matching": "[待翻译]Updating", + "download_source_no_information": "[待翻译]No information available", + "download_source_pending_matching": "[待翻译]Updating soon", + "download_sources_synced_successfully": "[待翻译]All download sources are synced", + "enable_steam_achievements": "[待翻译]Enable search for Steam achievements", + "failed_add_download_source": "[待翻译]Failed to add download source. Please try again.", + "hide_to_tray_on_game_start": "[待翻译]Hide Hydra to tray on game startup", + "hydra_cloud": "[待翻译]Hydra Cloud", + "importing": "[待翻译]Importing...", + "removed_all_download_sources": "[待翻译]All download sources removed" }, "notifications": { "download_complete": "下载完成", @@ -421,7 +585,8 @@ "game_card": { "no_downloads": "无可用下载选项", "available_one": "可用", - "available_other": "可用" + "available_other": "可用", + "calculating": "[待翻译]Calculating" }, "binary_not_found_modal": { "title": "程序未安装", @@ -515,7 +680,23 @@ "show_achievements_on_profile": "在您的个人资料上显示成就", "show_points_on_profile": "在您的个人资料上显示获得的积分", "stats": "统计", - "top_percentile": "前 {{percentile}}%" + "top_percentile": "前 {{percentile}}%", + "achievements_earned": "[待翻译]Achievements earned", + "amount_hours_short": "[待翻译]{{amount}}h", + "amount_minutes_short": "[待翻译]{{amount}}m", + "delete_review": "[待翻译]Delete Review", + "game_added_to_pinned": "[待翻译]Game added to pinned", + "game_removed_from_pinned": "[待翻译]Game removed from pinned", + "karma": "[待翻译]Karma", + "karma_count": "[待翻译]karma", + "karma_description": "[待翻译]Earned from positive likes on reviews", + "loading_reviews": "[待翻译]Loading reviews...", + "manual_playtime_tooltip": "[待翻译]This playtime has been manually updated", + "pinned": "[待翻译]Pinned", + "played_recently": "[待翻译]Played recently", + "playtime": "[待翻译]Playtime", + "sort_by": "[待翻译]Sort by:", + "user_reviews": "[待翻译]Reviews" }, "achievement": { "achievement_unlocked": "成就已解锁", @@ -546,4 +727,4 @@ "hydra_cloud_feature_found": "您刚刚发现了一个Hydra Cloud功能!", "learn_more": "了解更多" } -} +} \ No newline at end of file From 088feaffc20a5f851882f56208899a3755c34ad8 Mon Sep 17 00:00:00 2001 From: expload233 Date: Mon, 3 Nov 2025 19:02:11 +0800 Subject: [PATCH 08/36] add missing translation for zh-CN --- .../translation.backup.20251103_185046.json | 549 ------------------ src/locales/zh/translation.json | 122 ++-- 2 files changed, 61 insertions(+), 610 deletions(-) delete mode 100644 src/locales/zh/translation.backup.20251103_185046.json diff --git a/src/locales/zh/translation.backup.20251103_185046.json b/src/locales/zh/translation.backup.20251103_185046.json deleted file mode 100644 index 7cdd0c92..00000000 --- a/src/locales/zh/translation.backup.20251103_185046.json +++ /dev/null @@ -1,549 +0,0 @@ -{ - "language_name": "简体中文", - "app": { - "successfully_signed_in": "已成功登录" - }, - "home": { - "surprise_me": "向我推荐", - "no_results": "没有找到结果", - "start_typing": "键入以开始搜素...", - "hot": "当下热门", - "weekly": "📅本周热门游戏", - "achievements": "🏆尝试击败" - }, - "sidebar": { - "catalogue": "游戏目录", - "downloads": "下载中心", - "settings": "设置", - "my_library": "我的游戏库", - "downloading_metadata": "{{title}} (正在下载元数据…)", - "paused": "{{title}} (已暂停)", - "downloading": "{{title}} ({{percentage}} - 正在下载…)", - "filter": "筛选游戏库", - "home": "主页", - "queued": "{{title}} (已加入下载队列)", - "game_has_no_executable": "未选择游戏的可执行文件", - "sign_in": "登入", - "friends": "好友", - "favorites": "收藏", - "need_help": "需要帮助?", - "playable_button_title": "仅显示现在可以游玩的游戏" - }, - "header": { - "search": "搜索游戏", - "home": "主页", - "catalogue": "游戏目录", - "downloads": "下载中心", - "search_results": "搜索结果", - "settings": "设置", - "version_available_install": "版本 {{version}} 已可用. 点击此处重新启动并安装.", - "version_available_download": "版本 {{version}} 可用. 点击此处下载." - }, - "bottom_panel": { - "no_downloads_in_progress": "没有正在进行的下载", - "downloading_metadata": "正在下载{{title}}的元数据…", - "downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}", - "calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间...", - "checking_files": "正在校验 {{title}} 的文件... ({{percentage}} 已完成)", - "installation_complete": "安装完成", - "installation_complete_message": "通用可再发行组件安装成功", - "installing_common_redist": "{{log}}…" - }, - "catalogue": { - "clear_filters": "清除已选的 {{filterCount}} 项", - "developers": "开发商", - "download_sources": "下载源", - "filter_count": "{{filterCount}} 项可用", - "genres": "类型", - "publishers": "发行商", - "result_count": "{{resultCount}} 个结果", - "search": "筛选…", - "tags": "标签" - }, - "game_details": { - "open_download_options": "打开下载菜单", - "download_options_zero": "无可下载项", - "download_options_one": "{{count}}个可下载项", - "download_options_other": "{{count}}个可下载项", - "updated_at": "更新于{{updated_at}}", - "install": "安装", - "resume": "恢复", - "pause": "暂停", - "cancel": "取消", - "remove": "移除", - "space_left_on_disk": "磁盘剩余空间{{space}}", - "eta": "预计完成时间{{eta}}", - "calculating_eta": "正在计算剩余时间…", - "downloading_metadata": "正在下载元数据…", - "filter": "筛选重打包", - "requirements": "配置要求", - "minimum": "最低要求", - "recommended": "推荐要求", - "paused": "已暂停", - "release_date": "发布于{{date}}", - "publisher": "发行商{{publisher}}", - "hours": "小时", - "minutes": "分钟", - "amount_hours": "{{amount}}小时", - "amount_minutes": "{{amount}}分钟", - "accuracy": "准确度{{accuracy}}%", - "add_to_library": "添加到游戏库", - "remove_from_library": "从游戏库移除", - "no_downloads": "没有可用的下载", - "play_time": "游戏时长{{amount}}", - "last_time_played": "上次玩{{period}}", - "not_played_yet": "您还没有玩过{{title}}", - "next_suggestion": "下一个建议", - "play": "开始游戏", - "deleting": "正在删除安装程序…", - "close": "关闭", - "playing_now": "正在游戏中", - "change": "更改", - "repacks_modal_description": "选择您想要下载的重打包", - "select_folder_hint": "要更改默认文件夹,请访问<0>设置", - "download_now": "立即下载", - "no_shop_details": "无法检索商店详细信息.", - "download_options": "下载选项", - "download_path": "下载路径", - "previous_screenshot": "上一张截图", - "next_screenshot": "下一张截图", - "screenshot": "截图 {{number}}", - "open_screenshot": "打开截图 {{number}}", - "download_settings": "下载设置", - "downloader": "下载器", - "select_executable": "选择可执行文件", - "no_executable_selected": "没有可执行文件被指定", - "open_folder": "打开目录", - "open_download_location": "查看已下载的文件", - "create_shortcut": "创建桌面快捷方式", - "remove_files": "删除文件", - "remove_from_library_title": "你确定吗?", - "remove_from_library_description": "这将会把 {{game}} 从你的库中移除", - "options": "选项", - "executable_section_title": "可执行文件", - "executable_section_description": "点击 \"Play\" 时将执行的文件的路径", - "downloads_section_title": "下载", - "downloads_section_description": "查看此游戏的更新或其他版本", - "danger_zone_section_title": "危险操作", - "danger_zone_section_description": "从您的库或Hydra下载的文件中删除此游戏", - "download_in_progress": "下载进行中", - "download_paused": "下载暂停", - "last_downloaded_option": "上次下载的选项", - "create_shortcut_success": "成功创建快捷方式", - "create_shortcut_error": "创建快捷方式出错", - "nsfw_content_title": "本游戏包含不适合展示的内容", - "nsfw_content_description": "{{title}}包含可能不适合所有年龄段的内容。您确定要继续吗?", - "allow_nsfw_content": "继续", - "refuse_nsfw_content": "返回", - "stats": "统计数据", - "download_count": "下载量", - "player_count": "活跃玩家", - "download_error": "此下载选项不可用", - "download": "下载", - "executable_path_in_use": "可执行文件已经被以下游戏 \"{{game}}\" 使用", - "warning": "警告:", - "hydra_needs_to_remain_open": "对于此次下载,Hydra必须保持开启直至其完成。若海德拉在完成前关闭,您的进度将丢失。", - "achievements": "成就", - "achievements_count": "成就 {{unlockedCount}}/{{achievementsCount}}", - "cloud_save": "云存档", - "cloud_save_description": "将您的进度保存在云端,便可在任何设备上继续游戏。", - "backups": "备份", - "install_backup": "安装", - "delete_backup": "删除", - "create_backup": "新备份", - "last_backup_date": "最后一次备份于{{date}}", - "no_backup_preview": "未找到此游戏标题的存档", - "restoring_backup": "正在恢复备份({{progress}}已完成)…", - "uploading_backup": "上传备份中…", - "no_backups": "您尚未为这款游戏创建任何备份", - "backup_uploaded": "备份已上传", - "backup_deleted": "备份已删除", - "backup_restored": "备份已恢复", - "see_all_achievements": "查看所有成就", - "sign_in_to_see_achievements": "登入以查看所有成就", - "mapping_method_automatic": "自动", - "mapping_method_manual": "常规", - "mapping_method_label": "索引类型", - "files_automatically_mapped": "文件已自动索引", - "no_backups_created": "没有为此游戏创建过备份", - "manage_files": "管理文件", - "loading_save_preview": "正在查找要保存的游戏…", - "wine_prefix": "Wine 前置", - "wine_prefix_description": "运行该游戏所用的 Wine 前置", - "no_download_option_info": "无可用信息", - "backup_deletion_failed": "删除备份失败", - "max_number_of_artifacts_reached": "已达到该游戏备份上限", - "achievements_not_sync": "你的成就未同步", - "manage_files_description": "管理哪些文件要备份和恢复", - "select_folder": "选择文件夹", - "backup_from": "{{date}} 时备份", - "custom_backup_location_set": "自定义备份文件位置", - "artifact_name_label": "备份名称", - "artifact_name_placeholder": "为备份输入名称", - "artifact_renamed": "备份重命名成功", - "automatic_backup_from": "{{date}} 的自动备份", - "automatically_extract_downloaded_files": "自动解压下载的文件", - "backup_freeze_failed": "固定备份失败", - "backup_freeze_failed_description": "您必须至少保留一个空位用于自动备份", - "backup_frozen": "备份已固定", - "backup_unfrozen": "备份已取消固定", - "clear": "清除", - "create_start_menu_shortcut": "创建开始菜单快捷方式", - "create_steam_shortcut": "创建Steam快捷方式", - "download_error_gofile_quota_exceeded": "您已超出Gofile的月度配额。请等待配额重置。", - "download_error_not_cached_on_hydra": "此下载在Nimbus上不可用。", - "download_error_not_cached_on_real_debrid": "此下载在Real-Debrid上不可用,且暂不支持从Real-Debrid轮询下载状态。", - "download_error_not_cached_on_torbox": "此下载在TorBox上不可用,且暂不支持从TorBox轮询下载状态。", - "download_error_real_debrid_account_not_authorized": "您的Real-Debrid账户未被授权进行新下载。请检查您的账户设置并重试。", - "enable_automatic_cloud_sync": "启用自动云同步", - "freeze_backup": "固定以免被自动备份覆盖", - "game_added_to_favorites": "游戏已添加到收藏", - "game_removed_from_favorites": "游戏已从收藏中移除", - "invalid_wine_prefix_path": "无效的Wine前置路径", - "invalid_wine_prefix_path_description": "Wine前置的路径无效。请检查路径并重试。", - "launch_options": "启动选项", - "launch_options_description": "高级用户可以选择修改启动选项(实验性功能)", - "launch_options_placeholder": "未指定参数", - "max_length_field": "此字段必须少于 {{length}} 个字符", - "missing_wine_prefix": "在Linux上创建备份需要Wine前置", - "no_directory_selected": "未选择目录", - "no_write_permission": "无法下载到此目录。点击此处了解更多。", - "rename_artifact": "重命名备份", - "rename_artifact_description": "将备份重命名为更具描述性的名称", - "required_field": "此字段为必填项", - "reset_achievements": "重置成就", - "reset_achievements_description": "这将重置 {{game}} 的所有成就", - "reset_achievements_error": "重置成就失败", - "reset_achievements_success": "成就重置成功", - "reset_achievements_title": "您确定吗?", - "save_changes": "保存更改", - "unfreeze_backup": "取消固定", - "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改" - }, - "activation": { - "title": "激活 Hydra", - "installation_id": "安装ID:", - "enter_activation_code": "输入您的激活码", - "message": "如果你不知道在哪里请求这个,那么您将无法继续。", - "activate": "激活", - "loading": "加载中…" - }, - "downloads": { - "resume": "继续", - "pause": "暂停", - "eta": "预计完成时间{{eta}}", - "paused": "已暂停", - "verifying": "正在验证…", - "completed": "已完成", - "removed": "未下载", - "cancel": "取消", - "filter": "筛选已下载游戏", - "remove": "移除", - "downloading_metadata": "正在下载元数据…", - "deleting": "正在删除安装程序…", - "delete": "移除安装程序", - "delete_modal_title": "您确定吗?", - "delete_modal_description": "这将从您的电脑上移除所有的安装文件", - "install": "安装", - "download_in_progress": "进行中", - "queued_downloads": "在队列中的下载", - "downloads_completed": "已完成", - "queued": "下载列表", - "no_downloads_title": "空空如也", - "no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。", - "checking_files": "正在校验文件…", - "extract": "解压文件", - "extracting": "正在解压文件…", - "options": "管理", - "resume_seeding": "恢复做种", - "seeding": "做种中", - "stop_seeding": "停止做种" - }, - "settings": { - "downloads_path": "下载路径", - "change": "更改", - "notifications": "通知", - "enable_download_notifications": "下载完成时", - "enable_repack_list_notifications": "添加新重打包时", - "real_debrid_api_token_label": "Real-Debrid API 令牌", - "quit_app_instead_hiding": "关闭Hydra而不是最小化到托盘", - "launch_with_system": "系统启动时运行 Hydra", - "general": "通用", - "behavior": "行为", - "download_sources": "下载源", - "language": "语言", - "api_token": "API 令牌", - "enable_real_debrid": "启用 Real-Debrid", - "real_debrid_description": "Real-Debrid 是一个无限制的下载器,允许您以最快的互联网速度即时下载文件。", - "debrid_invalid_token": "无效的 API 令牌", - "debrid_api_token_hint": "您可以从<0>这里获取API密钥.", - "real_debrid_free_account_error": "账户 \"{{username}}\" 是免费账户。请订阅 Real-Debrid", - "debrid_linked_message": "账户 \"{{username}}\" 已链接", - "save_changes": "保存更改", - "changes_saved": "更改已成功保存", - "download_sources_description": "Hydra 将从这些源获取下载链接。源 URL 必须是直接链接到包含下载链接的 .json 文件。", - "validate_download_source": "验证", - "remove_download_source": "移除", - "cancel_button_confirmation_delete_all_sources": "不", - "confirm_button_confirmation_delete_all_sources": "是的,删除所有内容", - "description_confirmation_delete_all_sources": "您将删除所有下载源", - "title_confirmation_delete_all_sources": "删除所有下载源", - "removed_download_sources": "已删除字体", - "button_delete_all_sources": "删除所有下载源", - "add_download_source": "添加源", - "download_count_zero": "列表中无下载", - "download_count_one": "列表中有 {{countFormatted}} 个下载", - "download_count_other": "列表中有 {{countFormatted}} 个下载", - "download_source_url": "下载源 URL", - "add_download_source_description": "插入包含 .json 文件的 URL", - "download_source_up_to_date": "已更新", - "download_source_errored": "出错", - "sync_download_sources": "同步源", - "removed_download_source": "已移除下载源", - "added_download_source": "已添加下载源", - "download_sources_synced": "所有下载源已同步", - "insert_valid_json_url": "插入有效的 JSON 网址", - "found_download_option_zero": "未找到下载选项", - "found_download_option_one": "找到 {{countFormatted}} 个下载选项", - "found_download_option_other": "找到 {{countFormatted}} 个下载选项", - "import": "导入", - "public": "公开", - "private": "私密", - "friends_only": "仅限朋友", - "privacy": "隐私", - "profile_visibility": "资料可见性", - "profile_visibility_description": "选择谁可以查看您的个人资料和资料库", - "required_field": "该字段为必填字段", - "source_already_exists": "已添加此来源", - "must_be_valid_url": "来源必须是有效的 URL", - "blocked_users": "已屏蔽用户", - "user_unblocked": "用户已经被屏蔽", - "enable_achievement_notifications": "当成就解锁时", - "account": "账户", - "account_data_updated_successfully": "账户数据更新成功", - "achievement_custom_notification_position": "成就自定义通知位置", - "alignment": "对齐", - "appearance": "外观", - "become_subscriber": "成为Hydra Cloud用户", - "bill_sent_until": "您的下一张账单将在此日期前发送", - "bottom-center": "底部中央", - "bottom-left": "底部左侧", - "bottom-right": "底部右侧", - "cancel": "取消", - "clear_themes": "清除", - "common_redist": "通用可再发行组件", - "common_redist_description": "运行某些游戏需要通用可再发行组件。建议安装以避免问题。", - "create_real_debrid_account": "如果您还没有Real-Debrid账户,请点击此处", - "create_theme": "创建", - "create_theme_modal_description": "创建新主题以自定义Hydra的外观", - "create_theme_modal_title": "创建自定义主题", - "create_torbox_account": "如果您还没有TorBox账户,请点击此处", - "current_email": "当前邮箱:", - "default": "默认", - "delete_all_themes": "删除所有主题", - "delete_all_themes_description": "这将删除所有您的自定义主题", - "delete_theme": "删除主题", - "delete_theme_description": "这将删除主题 {{theme}}", - "disable_nsfw_alert": "禁用NSFW警告", - "edit_theme": "编辑主题", - "editor_tab_code": "代码", - "editor_tab_info": "信息", - "editor_tab_save": "保存", - "enable_achievement_custom_notifications": "启用成就自定义通知", - "enable_auto_install": "自动下载更新", - "enable_friend_request_notifications": "当收到好友请求时", - "enable_friend_start_game_notifications": "当好友开始游戏时", - "enable_torbox": "启用TorBox", - "error_importing_theme": "导入主题时出错", - "extract_files_by_default": "下载后默认解压文件", - "hidden": "隐藏", - "import_theme": "导入主题", - "import_theme_description": "您将从主题商店导入 {{theme}}", - "insert_theme_name": "输入主题名称", - "install_common_redist": "安装", - "installing_common_redist": "正在安装…", - "launch_minimized": "最小化启动Hydra", - "manage_subscription": "管理订阅", - "name_min_length": "主题名称必须至少3个字符长", - "no_email_account": "您尚未设置邮箱", - "no_subscription": "以最佳方式享受Hydra", - "no_themes": "看起来您还没有任何主题,但别担心,点击这里创建您的第一个杰作。", - "no_users_blocked": "您没有屏蔽任何用户", - "notification_preview": "成就通知预览", - "platinum": "白金", - "rare": "稀有", - "real_debrid_account_linked": "Real-Debrid账户已连接", - "renew_subscription": "续费Hydra Cloud", - "seed_after_download_complete": "下载完成后做种", - "set_theme": "设置主题", - "show_download_speed_in_megabytes": "以兆字节每秒显示下载速度", - "show_hidden_achievement_description": "在解锁前显示隐藏成就描述", - "subscription_active_until": "您的Hydra Cloud活跃至 {{date}}", - "subscription_expired_at": "您的订阅已于 {{date}} 到期", - "subscription_renew_cancelled": "自动续费已禁用", - "subscription_renews_on": "您的订阅将于 {{date}} 续费", - "test_notification": "测试通知", - "theme_imported": "主题导入成功", - "theme_name": "名称", - "top-center": "顶部中央", - "top-left": "顶部左侧", - "top-right": "顶部右侧", - "torbox_account_linked": "TorBox账户已连接", - "torbox_description": "TorBox是您的高级种子盒服务,甚至可与市场上最好的服务器相媲美。", - "unset_theme": "取消设置主题", - "update_email": "更新邮箱", - "update_password": "更新密码", - "variation": "变体", - "web_store": "网络商店" - }, - "notifications": { - "download_complete": "下载完成", - "game_ready_to_install": "{{title}} 已准备就绪", - "repack_list_updated": "重打包列表已更新", - "repack_count_one": "{{count}} 重打包已添加", - "repack_count_other": "{{count}} 重打包已添加", - "new_update_available": "版本 {{version}} 可用", - "restart_to_install_update": "重启 Hydra 以安装更新", - "notification_achievement_unlocked_title": "{{game}} 的成绩已解锁", - "notification_achievement_unlocked_body": "{{achievement}} 和其他 {{count}} 已解锁", - "extraction_complete": "解压完成", - "friend_started_playing_game": "{{displayName}} 开始玩游戏", - "game_extracted": "{{title}} 解压成功", - "new_friend_request_description": "{{displayName}} 向您发送了好友请求", - "new_friend_request_title": "新好友请求", - "test_achievement_notification_description": "非常酷,对吧?", - "test_achievement_notification_title": "这是一个测试通知" - }, - "system_tray": { - "open": "打开 Hydra", - "quit": "退出" - }, - "game_card": { - "no_downloads": "无可用下载选项", - "available_one": "可用", - "available_other": "可用" - }, - "binary_not_found_modal": { - "title": "程序未安装", - "description": "您的系统中找不到 Wine 或 Lutris 的可执行文件", - "instructions": "请检查在 Linux 发行版上安装这些软件的正确方法,以便游戏能够正常运行" - }, - "forms": { - "toggle_password_visibility": "切换密码可见性" - }, - "modal": { - "close": "关闭按钮" - }, - "user_profile": { - "amount_hours": "{{amount}} 小时", - "amount_minutes": "{{amount}} 分钟", - "last_time_played": "上次游玩时间 {{period}}", - "activity": "近期活动", - "library": "库", - "total_play_time": "总游戏时长", - "no_recent_activity_title": "Emmm… 这里暂时啥都没有", - "no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!", - "display_name": "昵称", - "saving": "保存中", - "save": "保存", - "edit_profile": "编辑资料", - "saved_successfully": "成功保存", - "try_again": "请重试", - "sign_out_modal_title": "你确定吗?", - "cancel": "取消", - "successfully_signed_out": "登出成功", - "sign_out": "登出", - "playing_for": "已经玩了{{amount}}", - "sign_out_modal_text": "您的资料库与您当前的账户相关联。注销后,您的资料库将不再可见,任何进度也不会保存。继续退出吗?", - "add_friends": "添加好友", - "add": "添加", - "friend_code": "好友代码", - "see_profile": "查看资料", - "sending": "发送中", - "friend_request_sent": "好友请求已发送", - "friends": "好友", - "friends_list": "好友列表", - "user_not_found": "未找到此用户", - "block_user": "屏蔽此用户", - "add_friend": "添加好友", - "request_sent": "请求已发送", - "request_received": "已收到请求", - "accept_request": "同意申请", - "ignore_request": "忽略申请", - "cancel_request": "取消申请", - "undo_friendship": "解除好友关系", - "request_accepted": "请求已通过", - "user_blocked_successfully": "成功屏蔽此用户", - "user_block_modal_text": "这将会屏蔽 {{displayName}}", - "blocked_users": "黑名单用户", - "unblock": "解除屏蔽", - "no_friends_added": "你还没有添加过好友", - "pending": "待处理", - "no_pending_invites": "您没有待处理的邀请", - "no_blocked_users": "你没有已经拉人黑名单的用户", - "friend_code_copied": "好友代码已复制", - "undo_friendship_modal_text": "这将使你与 {{displayName}} 解除好友关系", - "privacy_hint": "要调整谁可以看到你的个人资料,可以去<0>设置中修改", - "locked_profile": "此个人资料是私密的", - "image_process_failure": "处理图片时发生错误", - "required_field": "此字段为必填项", - "displayname_min_length": "显示名称最少必须为3个字符。", - "displayname_max_length": "显示名称最多必须为50个字符", - "report_profile": "举报此资料", - "report_reason": "为什么你要举报此资料?", - "report_description": "额外信息", - "report_description_placeholder": "额外信息", - "report": "举报", - "report_reason_hate": "仇恨言论", - "report_reason_sexual_content": "色情内容", - "report_reason_violence": "暴力", - "report_reason_spam": "骚扰", - "report_reason_other": "其他", - "profile_reported": "个人资料已举报", - "your_friend_code": "你的好友代码:", - "upload_banner": "上传横幅", - "uploading_banner": "上传横幅中…", - "background_image_updated": "背景图片已更新", - "achievements": "成就", - "achievements_unlocked": "成就已解锁", - "earned_points": "获得积分", - "error_adding_friend": "无法发送好友请求。请检查好友代码", - "friend_code_length_error": "好友代码必须为8个字符", - "games": "游戏", - "playing": "正在玩 {{game}}", - "ranking_updated_weekly": "排名每周更新", - "show_achievements_on_profile": "在您的个人资料上显示成就", - "show_points_on_profile": "在您的个人资料上显示获得的积分", - "stats": "统计", - "top_percentile": "前 {{percentile}}%" - }, - "achievement": { - "achievement_unlocked": "成就已解锁", - "user_achievements": "{{displayName}}的成就", - "your_achievements": "你的成就", - "unlocked_at": "解锁于: {{date}}", - "subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容", - "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就", - "achievement_earn_points": "通过此成就获得 {{points}} 积分", - "achievement_progress": "{{unlockedCount}}/{{totalCount}} 成就", - "achievements_unlocked_for_game": "为 {{gameTitle}} 解锁了 {{achievementCount}} 个新成就", - "available_points": "可用积分:", - "earned_points": "获得积分:", - "hidden_achievement_tooltip": "这是一个隐藏成就", - "how_to_earn_achievements_points": "如何获得成就积分?" - }, - "hydra_cloud": { - "subscription_tour_title": "Hydra 云订阅", - "subscribe_now": "现在订购", - "cloud_saving": "云存档", - "cloud_achievements": "将你的成就保存至云端", - "animated_profile_picture": "动画头像", - "premium_support": "高级技术支持", - "show_and_compare_achievements": "展示并与其他用户比较您的成就", - "animated_profile_banner": "动态个人简介横幅", - "debrid_description": "使用Nimbus下载速度提升4倍", - "hydra_cloud": "Hydra Cloud", - "hydra_cloud_feature_found": "您刚刚发现了一个Hydra Cloud功能!", - "learn_more": "了解更多" - } -} diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 6120b081..f668cb3b 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -28,67 +28,67 @@ "favorites": "收藏", "need_help": "需要帮助?", "playable_button_title": "仅显示现在可以游玩的游戏", - "add_custom_game_tooltip": "[待翻译]Add Custom Game", - "cancel": "[待翻译]Cancel", - "confirm": "[待翻译]Confirm", - "custom_game_modal": "[待翻译]Add Custom Game", - "custom_game_modal_add": "[待翻译]Add Game", - "custom_game_modal_adding": "[待翻译]Adding Game...", - "custom_game_modal_browse": "[待翻译]Browse", - "custom_game_modal_cancel": "[待翻译]Cancel", - "custom_game_modal_description": "[待翻译]Add a custom game to your library by selecting an executable file", - "custom_game_modal_enter_title": "[待翻译]Enter title", - "custom_game_modal_executable": "[待翻译]Executable", - "custom_game_modal_executable_path": "[待翻译]Executable Path", - "custom_game_modal_failed": "[待翻译]Failed to add custom game", - "custom_game_modal_select_executable": "[待翻译]Select executable file", - "custom_game_modal_success": "[待翻译]Custom game added successfully", - "custom_game_modal_title": "[待翻译]Title", - "decky_plugin_installation_error": "[待翻译]Error installing Decky plugin: {{error}}", - "decky_plugin_installation_failed": "[待翻译]Failed to install Decky plugin: {{error}}", - "decky_plugin_installed": "[待翻译]Decky plugin v{{version}} installed successfully", - "decky_plugin_installed_version": "[待翻译]Decky Plugin (v{{version}})", - "edit_game_modal": "[待翻译]Customize Assets", - "edit_game_modal_assets": "[待翻译]Assets", - "edit_game_modal_browse": "[待翻译]Browse", - "edit_game_modal_cancel": "[待翻译]Cancel", - "edit_game_modal_description": "[待翻译]Customize game assets and details", - "edit_game_modal_drop_hero_image_here": "[待翻译]Drop hero image here", - "edit_game_modal_drop_icon_image_here": "[待翻译]Drop icon image here", - "edit_game_modal_drop_logo_image_here": "[待翻译]Drop logo image here", - "edit_game_modal_drop_to_replace_hero": "[待翻译]Drop to replace hero", - "edit_game_modal_drop_to_replace_icon": "[待翻译]Drop to replace icon", - "edit_game_modal_drop_to_replace_logo": "[待翻译]Drop to replace logo", - "edit_game_modal_enter_title": "[待翻译]Enter title", - "edit_game_modal_failed": "[待翻译]Failed to update assets", - "edit_game_modal_fill_required": "[待翻译]Please fill in all required fields", - "edit_game_modal_hero": "[待翻译]Library Hero", - "edit_game_modal_hero_preview": "[待翻译]Library hero image preview", - "edit_game_modal_hero_resolution": "[待翻译]Recommended resolution: 1920x620px", - "edit_game_modal_icon": "[待翻译]Icon", - "edit_game_modal_icon_preview": "[待翻译]Icon preview", - "edit_game_modal_icon_resolution": "[待翻译]Recommended resolution: 256x256px", - "edit_game_modal_image": "[待翻译]Image", - "edit_game_modal_image_filter": "[待翻译]Image", - "edit_game_modal_image_preview": "[待翻译]Image preview", - "edit_game_modal_logo": "[待翻译]Logo", - "edit_game_modal_logo_preview": "[待翻译]Logo preview", - "edit_game_modal_logo_resolution": "[待翻译]Recommended resolution: 640x360px", - "edit_game_modal_select_hero": "[待翻译]Select library hero image", - "edit_game_modal_select_icon": "[待翻译]Select icon", - "edit_game_modal_select_image": "[待翻译]Select image", - "edit_game_modal_select_logo": "[待翻译]Select logo", - "edit_game_modal_success": "[待翻译]Assets updated successfully", - "edit_game_modal_title": "[待翻译]Title", - "edit_game_modal_update": "[待翻译]Update", - "edit_game_modal_updating": "[待翻译]Updating...", - "install_decky_plugin": "[待翻译]Install Decky Plugin", - "install_decky_plugin_message": "[待翻译]This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?", - "install_decky_plugin_title": "[待翻译]Install Hydra Decky Plugin", - "show_playable_only_tooltip": "[待翻译]Show Playable Only", - "update_decky_plugin": "[待翻译]Update Decky Plugin", - "update_decky_plugin_message": "[待翻译]A new version of the Hydra Decky plugin is available. Would you like to update it now?", - "update_decky_plugin_title": "[待翻译]Update Hydra Decky Plugin" + "add_custom_game_tooltip": "添加自定义游戏", + "cancel": "取消", + "confirm": "确认", + "custom_game_modal": "添加自定义游戏", + "custom_game_modal_add": "添加游戏", + "custom_game_modal_adding": "正在添加游戏...", + "custom_game_modal_browse": "浏览", + "custom_game_modal_cancel": "取消", + "custom_game_modal_description": "通过选择可执行文件将自定义游戏添加到您的库中", + "custom_game_modal_enter_title": "输入标题", + "custom_game_modal_executable": "可执行文件", + "custom_game_modal_executable_path": "可执行文件路径", + "custom_game_modal_failed": "添加自定义游戏失败", + "custom_game_modal_select_executable": "选择可执行文件", + "custom_game_modal_success": "自定义游戏添加成功", + "custom_game_modal_title": "标题", + "decky_plugin_installation_error": "安装 Decky 插件出错: {{error}}", + "decky_plugin_installation_failed": "Decky 插件安装失败: {{error}}", + "decky_plugin_installed": "Decky 插件 v{{version}} 安装成功", + "decky_plugin_installed_version": "Decky 插件 (v{{version}})", + "edit_game_modal": "自定义资源", + "edit_game_modal_assets": "资源", + "edit_game_modal_browse": "浏览", + "edit_game_modal_cancel": "取消", + "edit_game_modal_description": "自定义游戏资源和详情", + "edit_game_modal_drop_hero_image_here": "拖放主图像到此处", + "edit_game_modal_drop_icon_image_here": "拖放图标到此处", + "edit_game_modal_drop_logo_image_here": "拖放Logo到此处", + "edit_game_modal_drop_to_replace_hero": "拖放以替换主图像", + "edit_game_modal_drop_to_replace_icon": "拖放以替换图标", + "edit_game_modal_drop_to_replace_logo": "拖放以替换Logo", + "edit_game_modal_enter_title": "输入标题", + "edit_game_modal_failed": "资源更新失败", + "edit_game_modal_fill_required": "请填写所有必填项", + "edit_game_modal_hero": "库主图", + "edit_game_modal_hero_preview": "库主图预览", + "edit_game_modal_hero_resolution": "推荐分辨率: 1920x620px", + "edit_game_modal_icon": "图标", + "edit_game_modal_icon_preview": "图标预览", + "edit_game_modal_icon_resolution": "推荐分辨率: 256x256px", + "edit_game_modal_image": "图片", + "edit_game_modal_image_filter": "图片", + "edit_game_modal_image_preview": "图片预览", + "edit_game_modal_logo": "Logo", + "edit_game_modal_logo_preview": "Logo预览", + "edit_game_modal_logo_resolution": "推荐分辨率: 640x360px", + "edit_game_modal_select_hero": "选择库主图", + "edit_game_modal_select_icon": "选择图标", + "edit_game_modal_select_image": "选择图片", + "edit_game_modal_select_logo": "选择Logo", + "edit_game_modal_success": "资源更新成功", + "edit_game_modal_title": "标题", + "edit_game_modal_update": "更新", + "edit_game_modal_updating": "正在更新...", + "install_decky_plugin": "安装 Decky 插件", + "install_decky_plugin_message": "这将下载并安装 Hydra 的 Decky Loader 插件。可能需要提升权限。继续吗?", + "install_decky_plugin_title": "安装 Hydra Decky 插件", + "show_playable_only_tooltip": "仅显示可游玩", + "update_decky_plugin": "更新 Decky 插件", + "update_decky_plugin_message": "有新版本的 Hydra Decky 插件可用。现在要更新吗?", + "update_decky_plugin_title": "更新 Hydra Decky 插件" }, "header": { "search": "搜索游戏", From 66bb5221c13bd495867f60a4ceb965831f44dc2e Mon Sep 17 00:00:00 2001 From: expload233 Date: Tue, 4 Nov 2025 15:24:42 +0800 Subject: [PATCH 09/36] fix lint --- src/locales/zh/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index f668cb3b..c7fb7689 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -727,4 +727,4 @@ "hydra_cloud_feature_found": "您刚刚发现了一个Hydra Cloud功能!", "learn_more": "了解更多" } -} \ No newline at end of file +} From 5f56a3d51775adfbe495c63b374f967848d8dc95 Mon Sep 17 00:00:00 2001 From: expload233 Date: Tue, 4 Nov 2025 15:47:44 +0800 Subject: [PATCH 10/36] add translation --- src/locales/zh/translation.json | 240 ++++++++++++++++---------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index c7fb7689..bfc353d9 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -280,92 +280,92 @@ "save_changes": "保存更改", "unfreeze_backup": "取消固定", "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改", - "add_to_favorites": "[待翻译]Add to favorites", - "already_in_library": "[待翻译]Already in library", - "audio": "[待翻译]Audio", - "backup_failed": "[待翻译]Backup failed", - "be_first_to_review": "[待翻译]Be the first to share your thoughts about this game!", - "caption": "[待翻译]Caption", - "create_shortcut_simple": "[待翻译]Create shortcut", - "currency_country": "[待翻译]us", - "currency_symbol": "[待翻译]$", - "delete_review": "[待翻译]Delete review", - "delete_review_modal_cancel_button": "[待翻译]Cancel", - "delete_review_modal_delete_button": "[待翻译]Delete", - "delete_review_modal_description": "[待翻译]This action cannot be undone.", - "delete_review_modal_title": "[待翻译]Are you sure you want to delete your review?", - "edit_game_modal_button": "[待翻译]Customize game assets", - "failed_remove_files": "[待翻译]Failed to remove files", - "failed_remove_from_library": "[待翻译]Failed to remove from library", - "failed_update_favorites": "[待翻译]Failed to update favorites", - "files_removed_success": "[待翻译]Files removed successfully", - "filter_by_source": "[待翻译]Filter by source", - "game_added_to_pinned": "[待翻译]Game added to pinned", - "game_details": "[待翻译]Game Details", - "game_removed_from_library": "[待翻译]Game removed from library", - "game_removed_from_pinned": "[待翻译]Game removed from pinned", - "hide": "[待翻译]Hide", - "hide_original": "[待翻译]Hide original", - "historical_keyshop": "[待翻译]Historical keyshop", - "historical_retail": "[待翻译]Historical retail", - "keyshop_price": "[待翻译]Keyshop price", - "language": "[待翻译]Language", - "leave_a_review": "[待翻译]Leave a Review", - "load_more_reviews": "[待翻译]Load more reviews", - "loading_more_reviews": "[待翻译]Loading more reviews...", - "loading_reviews": "[待翻译]Loading reviews...", - "manual_playtime_tooltip": "[待翻译]This playtime has been manually updated", - "manual_playtime_warning": "[待翻译]Your hours will be marked as manually updated, and this cannot be undone.", - "maybe_later": "[待翻译]Maybe later", - "no_prices_found": "[待翻译]No prices found", - "no_repacks_found": "[待翻译]No sources found for this game", - "no_reviews_yet": "[待翻译]No reviews yet", - "prices": "[待翻译]Prices", - "properties": "[待翻译]Properties", - "rating": "[待翻译]Rating", - "rating_count": "[待翻译]Rating", - "rating_negative": "[待翻译]Negative", - "rating_neutral": "[待翻译]Neutral", - "rating_positive": "[待翻译]Positive", - "rating_stats": "[待翻译]Rating", - "rating_very_negative": "[待翻译]Very Negative", - "rating_very_positive": "[待翻译]Very Positive", - "remove_from_favorites": "[待翻译]Remove from favorites", - "remove_review": "[待翻译]Remove Review", - "retail_price": "[待翻译]Retail price", - "review_cannot_be_empty": "[待翻译]Review text field cannot be empty.", - "review_deleted_successfully": "[待翻译]Review deleted successfully.", - "review_deletion_failed": "[待翻译]Failed to delete review. Please try again.", - "review_from_blocked_user": "[待翻译]Review from blocked user", - "review_played_for": "[待翻译]Played for", - "review_submission_failed": "[待翻译]Failed to submit review. Please try again.", - "review_submitted_successfully": "[待翻译]Review submitted successfully!", - "reviews": "[待翻译]Reviews", - "show": "[待翻译]Show", - "show_less": "[待翻译]Show less", - "show_more": "[待翻译]Show more", - "show_original": "[待翻译]Show original", - "show_original_translated_from": "[待翻译]Show original (translated from {{language}})", - "show_translation": "[待翻译]Show translation", - "sort_highest_score": "[待翻译]Highest Score", - "sort_lowest_score": "[待翻译]Lowest Score", - "sort_most_voted": "[待翻译]Most Voted", - "sort_newest": "[待翻译]Newest", - "sort_oldest": "[待翻译]Oldest", - "submit_review": "[待翻译]Submit", - "submitting": "[待翻译]Submitting...", - "update_game_playtime": "[待翻译]Update game playtime", - "update_playtime": "[待翻译]Update playtime", - "update_playtime_description": "[待翻译]Manually update the playtime for {{game}}", - "update_playtime_error": "[待翻译]Failed to update playtime", - "update_playtime_success": "[待翻译]Playtime updated successfully", - "update_playtime_title": "[待翻译]Update playtime", - "view_all_prices": "[待翻译]Click to view all prices", - "vote_failed": "[待翻译]Failed to register your vote. Please try again.", - "would_you_recommend_this_game": "[待翻译]Would you like to leave a review to this game?", - "write_review_placeholder": "[待翻译]Share your thoughts about this game...", - "yes": "[待翻译]Yes", - "you_seemed_to_enjoy_this_game": "[待翻译]You've seemed to enjoy this game" + "add_to_favorites": "添加到收藏", + "already_in_library": "已在游戏库中", + "audio": "音频", + "backup_failed": "备份失败", + "be_first_to_review": "成为第一个分享游戏感受的人!", + "caption": "标题", + "create_shortcut_simple": "创建快捷方式", + "currency_country": "zh", + "currency_symbol": "¥", + "delete_review": "删除评价", + "delete_review_modal_cancel_button": "取消", + "delete_review_modal_delete_button": "删除", + "delete_review_modal_description": "此操作无法撤销。", + "delete_review_modal_title": "确定要删除您的评价吗?", + "edit_game_modal_button": "自定义游戏资源", + "failed_remove_files": "文件删除失败", + "failed_remove_from_library": "移出游戏库失败", + "failed_update_favorites": "收藏更新失败", + "files_removed_success": "文件已成功删除", + "filter_by_source": "按来源筛选", + "game_added_to_pinned": "游戏已添加到置顶", + "game_details": "游戏详情", + "game_removed_from_library": "游戏已从库中移除", + "game_removed_from_pinned": "游戏已从置顶移除", + "hide": "隐藏", + "hide_original": "隐藏原文", + "historical_keyshop": "历史密钥商店", + "historical_retail": "历史零售", + "keyshop_price": "密钥商店价格", + "language": "语言", + "leave_a_review": "留下评价", + "load_more_reviews": "加载更多评价", + "loading_more_reviews": "正在加载更多评价...", + "loading_reviews": "正在加载评价...", + "manual_playtime_tooltip": "该游戏时长已手动更新", + "manual_playtime_warning": "您的游戏时长将被标记为手动更新,且无法撤销。", + "maybe_later": "以后再说", + "no_prices_found": "未找到价格信息", + "no_repacks_found": "未找到该游戏的下载来源", + "no_reviews_yet": "暂无评价", + "prices": "价格", + "properties": "属性", + "rating": "评分", + "rating_count": "评分数", + "rating_negative": "差评", + "rating_neutral": "中性", + "rating_positive": "好评", + "rating_stats": "评分统计", + "rating_very_negative": "极差", + "rating_very_positive": "极好", + "remove_from_favorites": "移出收藏", + "remove_review": "移除评价", + "retail_price": "零售价格", + "review_cannot_be_empty": "评价内容不能为空。", + "review_deleted_successfully": "评价已成功删除。", + "review_deletion_failed": "评价删除失败,请重试。", + "review_from_blocked_user": "来自被屏蔽用户的评价", + "review_played_for": "已游玩", + "review_submission_failed": "评价提交失败,请重试。", + "review_submitted_successfully": "评价提交成功!", + "reviews": "评价", + "show": "显示", + "show_less": "收起", + "show_more": "展开", + "show_original": "显示原文", + "show_original_translated_from": "显示原文(由{{language}}翻译)", + "show_translation": "显示翻译", + "sort_highest_score": "最高分", + "sort_lowest_score": "最低分", + "sort_most_voted": "最多投票", + "sort_newest": "最新", + "sort_oldest": "最旧", + "submit_review": "提交", + "submitting": "正在提交...", + "update_game_playtime": "更新游戏时长", + "update_playtime": "更新时长", + "update_playtime_description": "手动更新 {{game}} 的游玩时长", + "update_playtime_error": "游戏时长更新失败", + "update_playtime_success": "游戏时长已成功更新", + "update_playtime_title": "更新游戏时长", + "view_all_prices": "点击查看所有价格", + "vote_failed": "投票失败,请重试。", + "would_you_recommend_this_game": "您想为此游戏留下评价吗?", + "write_review_placeholder": "分享您对本游戏的看法...", + "yes": "是", + "you_seemed_to_enjoy_this_game": "您似乎很喜欢这款游戏" }, "activation": { "title": "激活 Hydra", @@ -542,23 +542,23 @@ "update_password": "更新密码", "variation": "变体", "web_store": "网络商店", - "adding": "[待翻译]Adding…", - "autoplay_trailers_on_game_page": "[待翻译]Automatically start playing trailers on game page", - "debrid": "[待翻译]Debrid", - "debrid_description": "[待翻译]Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.", - "download_source_already_exists": "[待翻译]This download source URL already exists.", - "download_source_failed": "[待翻译]Error", - "download_source_matched": "[待翻译]Up to date", - "download_source_matching": "[待翻译]Updating", - "download_source_no_information": "[待翻译]No information available", - "download_source_pending_matching": "[待翻译]Updating soon", - "download_sources_synced_successfully": "[待翻译]All download sources are synced", - "enable_steam_achievements": "[待翻译]Enable search for Steam achievements", - "failed_add_download_source": "[待翻译]Failed to add download source. Please try again.", - "hide_to_tray_on_game_start": "[待翻译]Hide Hydra to tray on game startup", - "hydra_cloud": "[待翻译]Hydra Cloud", - "importing": "[待翻译]Importing...", - "removed_all_download_sources": "[待翻译]All download sources removed" + "adding": "添加中…", + "autoplay_trailers_on_game_page": "在游戏页面自动播放预告片", + "debrid": "Debrid下载服务", + "debrid_description": "Debrid服务是一种高级不限速下载器,可让您以最快的网速下载托管在各类网盘上的文件,仅受您的网络速度限制。", + "download_source_already_exists": "该下载源URL已存在。", + "download_source_failed": "出错", + "download_source_matched": "已更新", + "download_source_matching": "正在更新", + "download_source_no_information": "暂无信息", + "download_source_pending_matching": "即将更新", + "download_sources_synced_successfully": "所有下载源已同步", + "enable_steam_achievements": "启用Steam成就搜索", + "failed_add_download_source": "添加下载源失败,请重试。", + "hide_to_tray_on_game_start": "启动游戏时隐藏到托盘", + "hydra_cloud": "Hydra Cloud", + "importing": "导入中…", + "removed_all_download_sources": "已移除所有下载源" }, "notifications": { "download_complete": "下载完成", @@ -586,7 +586,7 @@ "no_downloads": "无可用下载选项", "available_one": "可用", "available_other": "可用", - "calculating": "[待翻译]Calculating" + "calculating": "正在计算" }, "binary_not_found_modal": { "title": "程序未安装", @@ -681,22 +681,22 @@ "show_points_on_profile": "在您的个人资料上显示获得的积分", "stats": "统计", "top_percentile": "前 {{percentile}}%", - "achievements_earned": "[待翻译]Achievements earned", - "amount_hours_short": "[待翻译]{{amount}}h", - "amount_minutes_short": "[待翻译]{{amount}}m", - "delete_review": "[待翻译]Delete Review", - "game_added_to_pinned": "[待翻译]Game added to pinned", - "game_removed_from_pinned": "[待翻译]Game removed from pinned", - "karma": "[待翻译]Karma", - "karma_count": "[待翻译]karma", - "karma_description": "[待翻译]Earned from positive likes on reviews", - "loading_reviews": "[待翻译]Loading reviews...", - "manual_playtime_tooltip": "[待翻译]This playtime has been manually updated", - "pinned": "[待翻译]Pinned", - "played_recently": "[待翻译]Played recently", - "playtime": "[待翻译]Playtime", - "sort_by": "[待翻译]Sort by:", - "user_reviews": "[待翻译]Reviews" + "achievements_earned": "已获得成就", + "amount_hours_short": "{{amount}}小时", + "amount_minutes_short": "{{amount}}分钟", + "delete_review": "删除评价", + "game_added_to_pinned": "游戏已添加到置顶", + "game_removed_from_pinned": "游戏已从置顶移除", + "karma": "业力", + "karma_count": "业力值", + "karma_description": "通过评论获得的点赞", + "loading_reviews": "正在加载评价...", + "manual_playtime_tooltip": "该游戏时长已手动更新", + "pinned": "已置顶", + "played_recently": "最近游玩", + "playtime": "游戏时长", + "sort_by": "排序方式:", + "user_reviews": "用户评价" }, "achievement": { "achievement_unlocked": "成就已解锁", From 3bef0c92695b31f54e0cba8d14dd5f75bd2ce5f1 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 6 Nov 2025 18:26:56 +0200 Subject: [PATCH 11/36] feat: library ui changes and searchbar removal --- src/locales/en/translation.json | 1 + src/renderer/src/app.scss | 3 +- .../src/components/header/header.scss | 4 +- src/renderer/src/components/header/header.tsx | 39 +++++++--- src/renderer/src/features/library-slice.ts | 7 +- .../src/pages/library/filter-options.scss | 4 +- .../library/library-game-card-large.scss | 24 ++---- .../src/pages/library/library-game-card.scss | 39 +++++----- .../src/pages/library/library-game-card.tsx | 2 +- src/renderer/src/pages/library/library.scss | 20 ++--- src/renderer/src/pages/library/library.tsx | 6 +- .../src/pages/library/search-bar.scss | 75 ------------------- src/renderer/src/pages/library/search-bar.tsx | 44 ----------- .../src/pages/library/view-options.scss | 6 +- 14 files changed, 82 insertions(+), 192 deletions(-) delete mode 100644 src/renderer/src/pages/library/search-bar.scss delete mode 100644 src/renderer/src/pages/library/search-bar.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9989f153..cb2473eb 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -93,6 +93,7 @@ }, "header": { "search": "Search games", + "search_library": "Search library", "home": "Home", "catalogue": "Catalogue", "library": "Library", diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index ed7b9aa8..0d992553 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -5,7 +5,7 @@ } ::-webkit-scrollbar { - width: 4px; + width: 9px; background-color: globals.$dark-background-color; } @@ -90,6 +90,7 @@ img { progress[value] { -webkit-appearance: none; + appearance: none; } .container { diff --git a/src/renderer/src/components/header/header.scss b/src/renderer/src/components/header/header.scss index cd25d8e2..f0c72ce0 100644 --- a/src/renderer/src/components/header/header.scss +++ b/src/renderer/src/components/header/header.scss @@ -24,7 +24,7 @@ background-color: globals.$background-color; display: inline-flex; transition: all ease 0.2s; - width: 300px; + width: 200px; align-items: center; border-radius: 8px; border: solid 1px globals.$border-color; @@ -35,7 +35,7 @@ } &--focused { - width: 350px; + width: 250px; border-color: #dadbe1; } } diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 0f452bf2..d3164ced 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -7,7 +7,7 @@ import { useAppDispatch, useAppSelector } from "@renderer/hooks"; import "./header.scss"; import { AutoUpdateSubHeader } from "./auto-update-sub-header"; -import { setFilters } from "@renderer/features"; +import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import cn from "classnames"; const pathTitle: Record = { @@ -28,10 +28,20 @@ export function Header() { (state) => state.window ); - const searchValue = useAppSelector( + const catalogueSearchValue = useAppSelector( (state) => state.catalogueSearch.filters.title ); + const librarySearchValue = useAppSelector( + (state) => state.library.searchQuery + ); + + const isOnLibraryPage = location.pathname.startsWith("/library"); + + const searchValue = isOnLibraryPage + ? librarySearchValue + : catalogueSearchValue; + const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); @@ -63,18 +73,29 @@ export function Header() { }; const handleSearch = (value: string) => { - dispatch(setFilters({ title: value.slice(0, 255) })); + if (isOnLibraryPage) { + dispatch(setLibrarySearchQuery(value.slice(0, 255))); + } else { + dispatch(setFilters({ title: value.slice(0, 255) })); + if (!location.pathname.startsWith("/catalogue")) { + navigate("/catalogue"); + } + } + }; - if (!location.pathname.startsWith("/catalogue")) { - navigate("/catalogue"); + const handleClearSearch = () => { + if (isOnLibraryPage) { + dispatch(setLibrarySearchQuery("")); + } else { + dispatch(setFilters({ title: "" })); } }; useEffect(() => { - if (!location.pathname.startsWith("/catalogue") && searchValue) { + if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) { dispatch(setFilters({ title: "" })); } - }, [location.pathname, searchValue, dispatch]); + }, [location.pathname, catalogueSearchValue, dispatch]); return ( <> @@ -123,7 +144,7 @@ export function Header() { ref={inputRef} type="text" name="search" - placeholder={t("search")} + placeholder={isOnLibraryPage ? t("search_library") : t("search")} value={searchValue} className="header__search-input" onChange={(event) => handleSearch(event.target.value)} @@ -134,7 +155,7 @@ export function Header() { {searchValue && ( - )} -
- - ); -}; diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss index 77bfc10e..13307864 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -26,7 +26,7 @@ display: flex; align-items: center; gap: calc(globals.$spacing-unit); - padding: 8px 10px; + padding: 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.04); border: none; @@ -38,8 +38,8 @@ white-space: nowrap; &:hover { - color: rgba(255, 255, 255, 0.95); - background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); } &.active { From a6cbaf6dc11b8cd031b8129eb729c91b43e12192 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 7 Nov 2025 17:48:56 +0200 Subject: [PATCH 12/36] feat: custom achievement sound and volume changing) --- src/locales/en/translation.json | 5 + src/main/constants.ts | 4 + src/main/events/index.ts | 4 + .../themes/copy-theme-achievement-sound.ts | 40 ++++++ .../events/themes/get-theme-sound-path.ts | 12 ++ .../themes/import-theme-sound-from-store.ts | 57 +++++++++ .../themes/remove-theme-achievement-sound.ts | 39 ++++++ src/main/helpers/index.ts | 24 ++++ src/main/services/notifications/index.ts | 45 ++++++- src/preload/index.ts | 8 ++ src/renderer/src/app.tsx | 11 +- src/renderer/src/declaration.d.ts | 11 ++ src/renderer/src/helpers.ts | 29 +++++ .../notification/achievement-notification.tsx | 11 +- .../aparence/modals/import-theme-modal.tsx | 10 ++ .../src/pages/settings/settings-general.scss | 96 ++++++++++++++ .../src/pages/settings/settings-general.tsx | 83 +++++++++++- .../src/pages/theme-editor/theme-editor.scss | 17 ++- .../src/pages/theme-editor/theme-editor.tsx | 121 +++++++++++++----- src/types/level.types.ts | 1 + src/types/theme.types.ts | 1 + 21 files changed, 583 insertions(+), 46 deletions(-) create mode 100644 src/main/events/themes/copy-theme-achievement-sound.ts create mode 100644 src/main/events/themes/get-theme-sound-path.ts create mode 100644 src/main/events/themes/import-theme-sound-from-store.ts create mode 100644 src/main/events/themes/remove-theme-achievement-sound.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9989f153..30b165ae 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -557,6 +557,11 @@ "platinum": "Platinum", "hidden": "Hidden", "test_notification": "Test notification", + "achievement_sound_volume": "Achievement sound volume", + "select_achievement_sound": "Select achievement sound", + "change_achievement_sound": "Change achievement sound", + "remove_achievement_sound": "Remove achievement sound", + "preview_sound": "Preview sound", "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", diff --git a/src/main/constants.ts b/src/main/constants.ts index 82b99b2a..3c4c10e5 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); +export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes"); + export const MAIN_LOOP_INTERVAL = 2000; +export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15; + export const DECKY_PLUGINS_LOCATION = path.join( SystemPath.getPath("home"), "homebrew", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index aaac89dd..89dd01f5 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -92,6 +92,10 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; +import "./themes/copy-theme-achievement-sound"; +import "./themes/remove-theme-achievement-sound"; +import "./themes/get-theme-sound-path"; +import "./themes/import-theme-sound-from-store"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; diff --git a/src/main/events/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts new file mode 100644 index 00000000..72ec0c79 --- /dev/null +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const copyThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + sourcePath: string +): Promise => { + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("Source file does not exist"); + } + + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const fileExtension = path.extname(sourcePath); + const destinationPath = path.join(themeDir, `achievement${fileExtension}`); + + await fs.promises.copyFile(sourcePath, destinationPath); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + updatedAt: new Date(), + }); +}; + +registerEvent("copyThemeAchievementSound", copyThemeAchievementSound); + diff --git a/src/main/events/themes/get-theme-sound-path.ts b/src/main/events/themes/get-theme-sound-path.ts new file mode 100644 index 00000000..5dccbd4e --- /dev/null +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -0,0 +1,12 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; + +const getThemeSoundPathEvent = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + return getThemeSoundPath(themeId); +}; + +registerEvent("getThemeSoundPath", getThemeSoundPathEvent); + diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts new file mode 100644 index 00000000..cd4c6fcd --- /dev/null +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -0,0 +1,57 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import axios from "axios"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { logger } from "@main/services"; + +const importThemeSoundFromStore = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + themeName: string, + storeUrl: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + try { + const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`; + + const response = await axios.get(soundUrl, { + responseType: "arraybuffer", + timeout: 10000, + }); + + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const destinationPath = path.join(themeDir, `achievement.${format}`); + await fs.promises.writeFile(destinationPath, response.data); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + updatedAt: new Date(), + }); + + logger.log(`Successfully imported sound for theme ${themeName}`); + return; + } catch (error) { + continue; + } + } + + logger.log(`No sound file found for theme ${themeName} in store`); +}; + +registerEvent("importThemeSoundFromStore", importThemeSoundFromStore); + diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts new file mode 100644 index 00000000..adb17a57 --- /dev/null +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -0,0 +1,39 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import path from "node:path"; + +const removeThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + return; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(themeDir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + await fs.promises.unlink(soundPath); + } + } + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: false, + updatedAt: new Date(), + }); +}; + +registerEvent("removeThemeAchievementSound", removeThemeAchievementSound); + diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 2da49a1c..ae19fbdb 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -2,6 +2,8 @@ import axios from "axios"; import { JSDOM } from "jsdom"; import UserAgent from "user-agents"; import path from "node:path"; +import fs from "node:fs"; +import { THEMES_PATH } from "@main/constants"; export const getFileBuffer = async (url: string) => fetch(url, { method: "GET" }).then((response) => @@ -36,4 +38,26 @@ export const normalizePath = (str: string) => export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; +export const getThemePath = (themeId: string) => + path.join(THEMES_PATH, themeId); + +export const getThemeSoundPath = (themeId: string): string | null => { + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + return null; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(themeDir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + return soundPath; + } + } + + return null; +}; + export * from "./reg-parser"; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index d28c3cd7..d78a4d3f 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -5,15 +5,16 @@ import fs from "node:fs"; import axios from "axios"; import path from "node:path"; import sound from "sound-play"; -import { achievementSoundPath } from "@main/constants"; +import { achievementSoundPath, DEFAULT_ACHIEVEMENT_SOUND_VOLUME } from "@main/constants"; import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; import { WindowManager } from "../window-manager"; import type { Game, UserPreferences, UserProfile } from "@types"; -import { db, levelKeys } from "@main/level"; +import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; +import { getThemeSoundPath } from "@main/helpers"; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -40,6 +41,40 @@ async function downloadImage(url: string | null) { }); } +async function getAchievementSoundPath(): Promise { + try { + const allThemes = await themesSublevel.values().all(); + const activeTheme = allThemes.find((theme) => theme.isActive); + + if (activeTheme) { + const themeSoundPath = getThemeSoundPath(activeTheme.id); + if (themeSoundPath) { + return themeSoundPath; + } + } + } catch (error) { + logger.error("Failed to get theme sound path", error); + } + + return achievementSoundPath; +} + +async function getAchievementSoundVolume(): Promise { + try { + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); + + return userPreferences?.achievementSoundVolume ?? DEFAULT_ACHIEVEMENT_SOUND_VOLUME; + } catch (error) { + logger.error("Failed to get achievement sound volume", error); + return DEFAULT_ACHIEVEMENT_SOUND_VOLUME; + } +} + export const publishDownloadCompleteNotification = async (game: Game) => { const userPreferences = await db.get( levelKeys.userPreferences, @@ -145,7 +180,8 @@ export const publishCombinedNewAchievementNotification = async ( if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; @@ -205,6 +241,7 @@ export const publishNewAchievementNotification = async (info: { if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index fc588a30..67ac0c73 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -571,6 +571,14 @@ contextBridge.exposeInMainWorld("electron", { getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"), toggleCustomTheme: (themeId: string, isActive: boolean) => ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), + copyThemeAchievementSound: (themeId: string, sourcePath: string) => + ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath), + removeThemeAchievementSound: (themeId: string) => + ipcRenderer.invoke("removeThemeAchievementSound", themeId), + getThemeSoundPath: (themeId: string) => + ipcRenderer.invoke("getThemeSoundPath", themeId), + importThemeSoundFromStore: (themeId: string, themeName: string, storeUrl: string) => + ipcRenderer.invoke("importThemeSoundFromStore", themeId, themeName, storeUrl), /* Editor */ openEditorWindow: (themeId: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 1ab76381..fd1c2735 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef } from "react"; -import achievementSound from "@renderer/assets/audio/achievement.wav"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -25,7 +24,7 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; -import { injectCustomCss, removeCustomCss } from "./helpers"; +import { injectCustomCss, removeCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "./helpers"; import "./app.scss"; export interface AppProps { @@ -216,9 +215,11 @@ export function App() { return () => unsubscribe(); }, [loadAndApplyTheme]); - const playAudio = useCallback(() => { - const audio = new Audio(achievementSound); - audio.volume = 0.2; + const playAudio = useCallback(async () => { + const soundUrl = await getAchievementSoundUrl(); + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; audio.play(); }, []); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 65f2ce9e..44696872 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -410,6 +410,17 @@ declare global { getCustomThemeById: (themeId: string) => Promise; getActiveCustomTheme: () => Promise; toggleCustomTheme: (themeId: string, isActive: boolean) => Promise; + copyThemeAchievementSound: ( + themeId: string, + sourcePath: string + ) => Promise; + removeThemeAchievementSound: (themeId: string) => Promise; + getThemeSoundPath: (themeId: string) => Promise; + importThemeSoundFromStore: ( + themeId: string, + themeName: string, + storeUrl: string + ) => Promise; /* Editor */ openEditorWindow: (themeId: string) => Promise; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index f09cec84..3ee04805 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -121,3 +121,32 @@ export const formatNumber = (num: number): string => { export const generateUUID = (): string => { return uuidv4(); }; + +export const getAchievementSoundUrl = async (): Promise => { + const defaultSound = (await import("@renderer/assets/audio/achievement.wav")).default; + + try { + const activeTheme = await window.electron.getActiveCustomTheme(); + + if (activeTheme?.hasCustomSound) { + const soundPath = await window.electron.getThemeSoundPath(activeTheme.id); + if (soundPath) { + return `file://${soundPath}`; + } + } + } catch (error) { + console.error("Failed to get theme sound", error); + } + + return defaultSound; +}; + +export const getAchievementSoundVolume = async (): Promise => { + try { + const prefs = await window.electron.getUserPreferences(); + return prefs?.achievementSoundVolume ?? 0.15; + } catch (error) { + console.error("Failed to get sound volume", error); + return 0.15; + } +}; diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index c5c37933..02023f1d 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import achievementSound from "@renderer/assets/audio/achievement.wav"; import { useTranslation } from "react-i18next"; import { AchievementCustomNotificationPosition, AchievementNotificationInfo, } from "@types"; -import { injectCustomCss, removeCustomCss } from "@renderer/helpers"; +import { injectCustomCss, removeCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import app from "../../../app.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; @@ -33,9 +32,11 @@ export function AchievementNotification() { const [shadowRootRef, setShadowRootRef] = useState(null); - const playAudio = useCallback(() => { - const audio = new Audio(achievementSound); - audio.volume = 0.1; + const playAudio = useCallback(async () => { + const soundUrl = await getAchievementSoundUrl(); + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; audio.play(); }, []); diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index 516f320f..93baf1cd 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -51,6 +51,16 @@ export const ImportThemeModal = ({ if (!currentTheme) return; + try { + await window.electron.importThemeSoundFromStore( + theme.id, + themeName, + THEME_WEB_STORE_URL + ); + } catch (soundError) { + logger.error("Failed to import theme sound", soundError); + } + const activeTheme = await window.electron.getActiveCustomTheme(); if (activeTheme) { diff --git a/src/renderer/src/pages/settings/settings-general.scss b/src/renderer/src/pages/settings/settings-general.scss index 302effa3..58004362 100644 --- a/src/renderer/src/pages/settings/settings-general.scss +++ b/src/renderer/src/pages/settings/settings-general.scss @@ -17,4 +17,100 @@ &__test-achievement-notification-button { align-self: flex-start; } + + &__volume-control { + display: flex; + flex-direction: column; + gap: 8px; + + label { + font-size: 14px; + color: globals.$muted-color; + } + } + + &__volume-input-wrapper { + display: flex; + align-items: center; + gap: 4px; + } + + &__volume-input-container { + position: relative; + display: flex; + align-items: center; + background: globals.$dark-background-color; + border: 1px solid globals.$border-color; + padding: 8px 8px; + border-radius: 4px; + transition: border-color 0.2s; + + &:focus-within { + border-color: rgba(255, 255, 255, 0.5); + color: globals.$muted-color; + } + + input[type="number"] { + width: 30px; + background: transparent; + border: none; + color: globals.$muted-color; + font-size: 14px; + text-align: center; + + &:focus { + outline: none; + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + appearance: textfield; + -moz-appearance: textfield; + } + } + + &__volume-input-unit { + font-size: 14px; + color: globals.$muted-color; + pointer-events: none; + user-select: none; + } + + &__volume-input-buttons { + display: flex; + flex-direction: column; + gap: 2px; + + button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 16px; + background: globals.$dark-background-color; + border: 1px solid globals.$border-color; + border-radius: 2px; + color: globals.$muted-color; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: globals.$muted-color; + border-color: rgba(255, 255, 255, 0.5); + } + + &:active { + background: globals.$background-color; + } + + svg { + width: 12px; + height: 12px; + } + } + } } diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index c698440d..6d81f763 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { TextField, Button, @@ -43,6 +43,7 @@ export function SettingsGeneral() { achievementCustomNotificationsEnabled: true, achievementCustomNotificationPosition: "top-left" as AchievementCustomNotificationPosition, + achievementSoundVolume: 15, language: "", customStyles: window.localStorage.getItem("customStyles") || "", }); @@ -50,6 +51,8 @@ export function SettingsGeneral() { const [languageOptions, setLanguageOptions] = useState([]); const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); + + const volumeUpdateTimeoutRef = useRef(); useEffect(() => { window.electron.getDefaultDownloadsPath().then((path) => { @@ -81,6 +84,9 @@ export function SettingsGeneral() { return () => { clearInterval(interval); + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } }; }, []); @@ -110,6 +116,7 @@ export function SettingsGeneral() { userPreferences.achievementCustomNotificationsEnabled ?? true, achievementCustomNotificationPosition: userPreferences.achievementCustomNotificationPosition ?? "top-left", + achievementSoundVolume: Math.round((userPreferences.achievementSoundVolume ?? 0.15) * 100), friendRequestNotificationsEnabled: userPreferences.friendRequestNotificationsEnabled ?? false, friendStartGameNotificationsEnabled: @@ -148,6 +155,18 @@ export function SettingsGeneral() { await updateUserPreferences(values); }; + const handleVolumeChange = useCallback((newVolume: number) => { + setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume })); + + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } + + volumeUpdateTimeoutRef.current = setTimeout(() => { + updateUserPreferences({ achievementSoundVolume: newVolume / 100 }); + }, 300); + }, [updateUserPreferences]); + const handleChangeAchievementCustomNotificationPosition = async ( event: React.ChangeEvent ) => { @@ -309,6 +328,68 @@ export function SettingsGeneral() { )} + {form.achievementNotificationsEnabled && ( +
+ +
+
+ { + const value = e.target.value; + if (value === "") { + handleVolumeChange(0); + return; + } + const volumePercent = Math.min(100, Math.max(0, parseInt(value, 10))); + if (!isNaN(volumePercent)) { + handleVolumeChange(volumePercent); + } + }} + onBlur={(e) => { + if (e.target.value === "" || isNaN(parseInt(e.target.value, 10))) { + handleVolumeChange(0); + } + }} + /> + % +
+
+ + +
+
+
+ )} +

{t("common_redist")}

diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss index 38061c88..b34217f9 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -93,12 +93,25 @@ &__notification-preview { padding-top: 12px; display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; gap: 16px; &__select-variation { flex: inherit; } } + + &__notification-controls { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + } + + &__sound-controls { + display: flex; + flex-direction: row; + gap: 8px; + flex-wrap: wrap; + } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 9df3e9f4..8ae86f63 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -4,10 +4,10 @@ import Editor from "@monaco-editor/react"; import { AchievementCustomNotificationPosition, Theme } from "@types"; import { useSearchParams } from "react-router-dom"; import { Button, SelectField } from "@renderer/components"; -import { CheckIcon } from "@primer/octicons-react"; +import { CheckIcon, UploadIcon, TrashIcon, PlayIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import cn from "classnames"; -import { injectCustomCss } from "@renderer/helpers"; +import { injectCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; @@ -107,6 +107,46 @@ export default function ThemeEditor() { } }; + const handleSelectSound = useCallback(async () => { + if (!theme) return; + + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: "Audio", + extensions: ["wav", "mp3", "ogg", "m4a"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + await window.electron.copyThemeAchievementSound(theme.id, filePaths[0]); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + } + } + }, [theme]); + + const handleRemoveSound = useCallback(async () => { + if (!theme) return; + + await window.electron.removeThemeAchievementSound(theme.id); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + } + }, [theme]); + + const handlePreviewSound = useCallback(async () => { + const soundUrl = await getAchievementSoundUrl(); + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; + audio.play(); + }, []); + const achievementCustomNotificationPositionOptions = useMemo(() => { return [ "top-left", @@ -164,35 +204,58 @@ export default function ThemeEditor() {

- { - return { - key: variation, - value: variation, - label: t(variation), - }; +
+ { + return { + key: variation, + value: variation, + label: t(variation), + }; + } + )} + onChange={(value) => + setNotificationVariation( + value.target.value as keyof typeof notificationVariations + ) } - )} - onChange={(value) => - setNotificationVariation( - value.target.value as keyof typeof notificationVariations - ) - } - /> + /> - - setNotificationAlignment( - e.target.value as AchievementCustomNotificationPosition - ) - } - options={achievementCustomNotificationPositionOptions} - /> + + setNotificationAlignment( + e.target.value as AchievementCustomNotificationPosition + ) + } + options={achievementCustomNotificationPositionOptions} + /> +
+ +
+ + + {theme?.hasCustomSound && ( + + )} + + +
diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 1df55b9e..c3f799fa 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -113,6 +113,7 @@ export interface UserPreferences { achievementNotificationsEnabled?: boolean; achievementCustomNotificationsEnabled?: boolean; achievementCustomNotificationPosition?: AchievementCustomNotificationPosition; + achievementSoundVolume?: number; friendRequestNotificationsEnabled?: boolean; friendStartGameNotificationsEnabled?: boolean; showDownloadSpeedInMegabytes?: boolean; diff --git a/src/types/theme.types.ts b/src/types/theme.types.ts index abba8fc1..94285d9c 100644 --- a/src/types/theme.types.ts +++ b/src/types/theme.types.ts @@ -5,6 +5,7 @@ export interface Theme { authorName?: string; isActive: boolean; code: string; + hasCustomSound?: boolean; createdAt: Date; updatedAt: Date; } From 154b6271a1379acf23d4799598ab872fd9565663 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 7 Nov 2025 17:50:47 +0200 Subject: [PATCH 13/36] fix: removed unused function --- .../themes/copy-theme-achievement-sound.ts | 1 - .../events/themes/get-theme-sound-path.ts | 1 - .../themes/import-theme-sound-from-store.ts | 3 +- .../themes/remove-theme-achievement-sound.ts | 1 - src/main/services/notifications/index.ts | 18 +---- src/preload/index.ts | 13 +++- src/renderer/src/app.tsx | 7 +- src/renderer/src/helpers.ts | 9 ++- .../notification/achievement-notification.tsx | 7 +- .../src/pages/settings/settings-general.tsx | 74 ++++++++++++++----- .../src/pages/theme-editor/theme-editor.tsx | 13 +++- 11 files changed, 95 insertions(+), 52 deletions(-) diff --git a/src/main/events/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts index 72ec0c79..aec22cb2 100644 --- a/src/main/events/themes/copy-theme-achievement-sound.ts +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -37,4 +37,3 @@ const copyThemeAchievementSound = async ( }; registerEvent("copyThemeAchievementSound", copyThemeAchievementSound); - diff --git a/src/main/events/themes/get-theme-sound-path.ts b/src/main/events/themes/get-theme-sound-path.ts index 5dccbd4e..37783949 100644 --- a/src/main/events/themes/get-theme-sound-path.ts +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -9,4 +9,3 @@ const getThemeSoundPathEvent = async ( }; registerEvent("getThemeSoundPath", getThemeSoundPathEvent); - diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts index cd4c6fcd..588db6f5 100644 --- a/src/main/events/themes/import-theme-sound-from-store.ts +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -22,7 +22,7 @@ const importThemeSoundFromStore = async ( for (const format of formats) { try { const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`; - + const response = await axios.get(soundUrl, { responseType: "arraybuffer", timeout: 10000, @@ -54,4 +54,3 @@ const importThemeSoundFromStore = async ( }; registerEvent("importThemeSoundFromStore", importThemeSoundFromStore); - diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts index adb17a57..4ca738f3 100644 --- a/src/main/events/themes/remove-theme-achievement-sound.ts +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -36,4 +36,3 @@ const removeThemeAchievementSound = async ( }; registerEvent("removeThemeAchievementSound", removeThemeAchievementSound); - diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index d78a4d3f..f1df1478 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import axios from "axios"; import path from "node:path"; import sound from "sound-play"; -import { achievementSoundPath, DEFAULT_ACHIEVEMENT_SOUND_VOLUME } from "@main/constants"; +import { achievementSoundPath } from "@main/constants"; import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; @@ -59,22 +59,6 @@ async function getAchievementSoundPath(): Promise { return achievementSoundPath; } -async function getAchievementSoundVolume(): Promise { - try { - const userPreferences = await db.get( - levelKeys.userPreferences, - { - valueEncoding: "json", - } - ); - - return userPreferences?.achievementSoundVolume ?? DEFAULT_ACHIEVEMENT_SOUND_VOLUME; - } catch (error) { - logger.error("Failed to get achievement sound volume", error); - return DEFAULT_ACHIEVEMENT_SOUND_VOLUME; - } -} - export const publishDownloadCompleteNotification = async (game: Game) => { const userPreferences = await db.get( levelKeys.userPreferences, diff --git a/src/preload/index.ts b/src/preload/index.ts index 67ac0c73..951591a5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -577,8 +577,17 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeThemeAchievementSound", themeId), getThemeSoundPath: (themeId: string) => ipcRenderer.invoke("getThemeSoundPath", themeId), - importThemeSoundFromStore: (themeId: string, themeName: string, storeUrl: string) => - ipcRenderer.invoke("importThemeSoundFromStore", themeId, themeName, storeUrl), + importThemeSoundFromStore: ( + themeId: string, + themeName: string, + storeUrl: string + ) => + ipcRenderer.invoke( + "importThemeSoundFromStore", + themeId, + themeName, + storeUrl + ), /* Editor */ openEditorWindow: (themeId: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index fd1c2735..272551ad 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -24,7 +24,12 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; -import { injectCustomCss, removeCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "./helpers"; +import { + injectCustomCss, + removeCustomCss, + getAchievementSoundUrl, + getAchievementSoundVolume, +} from "./helpers"; import "./app.scss"; export interface AppProps { diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 3ee04805..d5326de0 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -123,11 +123,12 @@ export const generateUUID = (): string => { }; export const getAchievementSoundUrl = async (): Promise => { - const defaultSound = (await import("@renderer/assets/audio/achievement.wav")).default; - + const defaultSound = (await import("@renderer/assets/audio/achievement.wav")) + .default; + try { const activeTheme = await window.electron.getActiveCustomTheme(); - + if (activeTheme?.hasCustomSound) { const soundPath = await window.electron.getThemeSoundPath(activeTheme.id); if (soundPath) { @@ -137,7 +138,7 @@ export const getAchievementSoundUrl = async (): Promise => { } catch (error) { console.error("Failed to get theme sound", error); } - + return defaultSound; }; diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index 02023f1d..38b2443b 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -4,7 +4,12 @@ import { AchievementCustomNotificationPosition, AchievementNotificationInfo, } from "@types"; -import { injectCustomCss, removeCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "@renderer/helpers"; +import { + injectCustomCss, + removeCustomCss, + getAchievementSoundUrl, + getAchievementSoundVolume, +} from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import app from "../../../app.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index 6d81f763..172b3291 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -1,4 +1,11 @@ -import { useContext, useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { + useContext, + useEffect, + useMemo, + useState, + useCallback, + useRef, +} from "react"; import { TextField, Button, @@ -51,7 +58,7 @@ export function SettingsGeneral() { const [languageOptions, setLanguageOptions] = useState([]); const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); - + const volumeUpdateTimeoutRef = useRef(); useEffect(() => { @@ -116,7 +123,9 @@ export function SettingsGeneral() { userPreferences.achievementCustomNotificationsEnabled ?? true, achievementCustomNotificationPosition: userPreferences.achievementCustomNotificationPosition ?? "top-left", - achievementSoundVolume: Math.round((userPreferences.achievementSoundVolume ?? 0.15) * 100), + achievementSoundVolume: Math.round( + (userPreferences.achievementSoundVolume ?? 0.15) * 100 + ), friendRequestNotificationsEnabled: userPreferences.friendRequestNotificationsEnabled ?? false, friendStartGameNotificationsEnabled: @@ -155,17 +164,20 @@ export function SettingsGeneral() { await updateUserPreferences(values); }; - const handleVolumeChange = useCallback((newVolume: number) => { - setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume })); - - if (volumeUpdateTimeoutRef.current) { - clearTimeout(volumeUpdateTimeoutRef.current); - } - - volumeUpdateTimeoutRef.current = setTimeout(() => { - updateUserPreferences({ achievementSoundVolume: newVolume / 100 }); - }, 300); - }, [updateUserPreferences]); + const handleVolumeChange = useCallback( + (newVolume: number) => { + setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume })); + + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } + + volumeUpdateTimeoutRef.current = setTimeout(() => { + updateUserPreferences({ achievementSoundVolume: newVolume / 100 }); + }, 300); + }, + [updateUserPreferences] + ); const handleChangeAchievementCustomNotificationPosition = async ( event: React.ChangeEvent @@ -347,13 +359,19 @@ export function SettingsGeneral() { handleVolumeChange(0); return; } - const volumePercent = Math.min(100, Math.max(0, parseInt(value, 10))); + const volumePercent = Math.min( + 100, + Math.max(0, parseInt(value, 10)) + ); if (!isNaN(volumePercent)) { handleVolumeChange(volumePercent); } }} onBlur={(e) => { - if (e.target.value === "" || isNaN(parseInt(e.target.value, 10))) { + if ( + e.target.value === "" || + isNaN(parseInt(e.target.value, 10)) + ) { handleVolumeChange(0); } }} @@ -365,11 +383,19 @@ export function SettingsGeneral() { type="button" onClick={(e) => { e.preventDefault(); - const newVolume = Math.min(100, form.achievementSoundVolume + 1); + const newVolume = Math.min( + 100, + form.achievementSoundVolume + 1 + ); handleVolumeChange(newVolume); }} > - + @@ -377,11 +403,19 @@ export function SettingsGeneral() { type="button" onClick={(e) => { e.preventDefault(); - const newVolume = Math.max(0, form.achievementSoundVolume - 1); + const newVolume = Math.max( + 0, + form.achievementSoundVolume - 1 + ); handleVolumeChange(newVolume); }} > - + diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 8ae86f63..6057084f 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -4,10 +4,19 @@ import Editor from "@monaco-editor/react"; import { AchievementCustomNotificationPosition, Theme } from "@types"; import { useSearchParams } from "react-router-dom"; import { Button, SelectField } from "@renderer/components"; -import { CheckIcon, UploadIcon, TrashIcon, PlayIcon } from "@primer/octicons-react"; +import { + CheckIcon, + UploadIcon, + TrashIcon, + PlayIcon, +} from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import cn from "classnames"; -import { injectCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "@renderer/helpers"; +import { + injectCustomCss, + getAchievementSoundUrl, + getAchievementSoundVolume, +} from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; From b6bbf05da6115de40589b6d8b346eef2af9824cf Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 7 Nov 2025 20:12:50 +0200 Subject: [PATCH 14/36] fix: theme editor layout positioning --- src/main/events/index.ts | 1 + .../events/themes/get-theme-sound-data-url.ts | 38 ++++++++ src/preload/index.ts | 2 + src/renderer/src/declaration.d.ts | 1 + src/renderer/src/helpers.ts | 8 +- .../src/pages/theme-editor/theme-editor.scss | 26 ++++- .../src/pages/theme-editor/theme-editor.tsx | 94 ++++++++++--------- 7 files changed, 116 insertions(+), 54 deletions(-) create mode 100644 src/main/events/themes/get-theme-sound-data-url.ts diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 89dd01f5..834f47ba 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -95,6 +95,7 @@ import "./themes/toggle-custom-theme"; import "./themes/copy-theme-achievement-sound"; import "./themes/remove-theme-achievement-sound"; import "./themes/get-theme-sound-path"; +import "./themes/get-theme-sound-data-url"; import "./themes/import-theme-sound-from-store"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; diff --git a/src/main/events/themes/get-theme-sound-data-url.ts b/src/main/events/themes/get-theme-sound-data-url.ts new file mode 100644 index 00000000..b9ace306 --- /dev/null +++ b/src/main/events/themes/get-theme-sound-data-url.ts @@ -0,0 +1,38 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import fs from "node:fs"; +import path from "node:path"; +import { logger } from "@main/services"; + +const getThemeSoundDataUrl = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + try { + const soundPath = getThemeSoundPath(themeId); + + if (!soundPath || !fs.existsSync(soundPath)) { + return null; + } + + const buffer = await fs.promises.readFile(soundPath); + const ext = path.extname(soundPath).toLowerCase().slice(1); + + const mimeTypes: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + m4a: "audio/mp4", + }; + + const mimeType = mimeTypes[ext] || "audio/mpeg"; + const base64 = buffer.toString("base64"); + + return `data:${mimeType};base64,${base64}`; + } catch (error) { + logger.error("Failed to get theme sound data URL", error); + return null; + } +}; + +registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl); diff --git a/src/preload/index.ts b/src/preload/index.ts index 951591a5..bfb1de6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -577,6 +577,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeThemeAchievementSound", themeId), getThemeSoundPath: (themeId: string) => ipcRenderer.invoke("getThemeSoundPath", themeId), + getThemeSoundDataUrl: (themeId: string) => + ipcRenderer.invoke("getThemeSoundDataUrl", themeId), importThemeSoundFromStore: ( themeId: string, themeName: string, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 44696872..9eefb477 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -416,6 +416,7 @@ declare global { ) => Promise; removeThemeAchievementSound: (themeId: string) => Promise; getThemeSoundPath: (themeId: string) => Promise; + getThemeSoundDataUrl: (themeId: string) => Promise; importThemeSoundFromStore: ( themeId: string, themeName: string, diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index d5326de0..e16aa7a4 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -130,9 +130,11 @@ export const getAchievementSoundUrl = async (): Promise => { const activeTheme = await window.electron.getActiveCustomTheme(); if (activeTheme?.hasCustomSound) { - const soundPath = await window.electron.getThemeSoundPath(activeTheme.id); - if (soundPath) { - return `file://${soundPath}`; + const soundDataUrl = await window.electron.getThemeSoundDataUrl( + activeTheme.id + ); + if (soundDataUrl) { + return soundDataUrl; } } } catch (error) { diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss index b34217f9..2d3d9067 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -47,6 +47,8 @@ position: relative; border: 1px solid globals.$muted-color; border-radius: 2px; + flex: 1; + min-width: 0; } &__footer { @@ -80,7 +82,7 @@ } &__info { - padding: 16px; + padding: 8px; p { font-size: 16px; @@ -93,25 +95,39 @@ &__notification-preview { padding-top: 12px; display: flex; - flex-direction: column; + flex-direction: row; gap: 16px; + align-items: flex-start; &__select-variation { flex: inherit; } } + &__notification-preview-controls { + display: flex; + flex-direction: column; + gap: 16px; + flex-shrink: 0; + } + &__notification-controls { display: flex; flex-direction: row; align-items: center; - gap: 16px; + gap: 8px; } &__sound-controls { display: flex; - flex-direction: row; + flex-direction: column; gap: 8px; - flex-wrap: wrap; + width: fit-content; + + button, + .button { + width: auto; + align-self: flex-start; + } } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 6057084f..75df5e1e 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -213,57 +213,59 @@ export default function ThemeEditor() {
-
- { - return { - key: variation, - value: variation, - label: t(variation), - }; +
+
+ { + return { + key: variation, + value: variation, + label: t(variation), + }; + } + )} + onChange={(value) => + setNotificationVariation( + value.target.value as keyof typeof notificationVariations + ) } - )} - onChange={(value) => - setNotificationVariation( - value.target.value as keyof typeof notificationVariations - ) - } - /> + /> - - setNotificationAlignment( - e.target.value as AchievementCustomNotificationPosition - ) - } - options={achievementCustomNotificationPositionOptions} - /> -
+ + setNotificationAlignment( + e.target.value as AchievementCustomNotificationPosition + ) + } + options={achievementCustomNotificationPositionOptions} + /> +
-
- - - {theme?.hasCustomSound && ( - - )} - + {theme?.hasCustomSound && ( + + )} + + +
From c3a4990a507cca27396e5dbe537e898e66df22dc Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 8 Nov 2025 14:28:54 +0200 Subject: [PATCH 15/36] ci: performance optimizing in library --- .../pages/library/library-game-card-large.tsx | 330 +++++++++--------- .../src/pages/library/library-game-card.tsx | 234 ++++++------- src/renderer/src/pages/library/library.tsx | 131 +++++-- 3 files changed, 369 insertions(+), 326 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 5628fe10..4cb54977 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -12,14 +12,18 @@ import { XIcon, } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; -import { useCallback, useState } from "react"; +import { useCallback, memo, useMemo } from "react"; import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { GameContextMenu } from "@renderer/components"; +import { logger } from "@renderer/logger"; import "./library-game-card-large.scss"; interface LibraryGameCardLargeProps { game: LibraryGame; + onContextMenu: ( + game: LibraryGame, + position: { x: number; y: number } + ) => void; } const getImageWithCustomPriority = ( @@ -30,17 +34,14 @@ const getImageWithCustomPriority = ( return customUrl || originalUrl || fallbackUrl || ""; }; -export function LibraryGameCardLarge({ +export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ game, + onContextMenu, }: Readonly) { const { t } = useTranslation("library"); const { numberFormatter } = useFormat(); const navigate = useNavigate(); const { lastPacket } = useDownload(); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - }>({ visible: false, position: { x: 0, y: 0 } }); const isGameDownloading = game?.download?.status === "active" && lastPacket?.gameId === game?.id; @@ -84,196 +85,193 @@ export function LibraryGameCardLarge({ try { await handleCloseGame(); } catch (e) { - console.error(e); + logger.error(e); } return; } try { await handlePlayGame(); } catch (err) { - console.error(err); + logger.error(err); try { handleOpenDownloadOptions(); } catch (e) { - console.error(e); + logger.error(e); } } }; - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setContextMenu({ - visible: true, - position: { x: e.clientX, y: e.clientY }, - }); - }; - - const handleMenuButtonClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setContextMenu({ - visible: true, - position: { - x: e.currentTarget.getBoundingClientRect().right, - y: e.currentTarget.getBoundingClientRect().bottom, - }, - }); - }; - - const handleCloseContextMenu = () => { - setContextMenu({ visible: false, position: { x: 0, y: 0 } }); - }; - - // Use libraryHeroImageUrl as background, fallback to libraryImageUrl - const backgroundImage = getImageWithCustomPriority( - game.libraryHeroImageUrl, - game.libraryImageUrl, - game.iconUrl + const handleContextMenuClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(game, { x: e.clientX, y: e.clientY }); + }, + [game, onContextMenu] + ); + + const handleMenuButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onContextMenu(game, { x: rect.right, y: rect.bottom }); + }, + [game, onContextMenu] + ); + + const backgroundImage = useMemo( + () => + getImageWithCustomPriority( + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl + ), + [game.libraryHeroImageUrl, game.libraryImageUrl, game.iconUrl] + ); + + const backgroundStyle = useMemo( + () => ({ backgroundImage: `url(${backgroundImage})` }), + [backgroundImage] + ); + + const achievementBarStyle = useMemo( + () => ({ + width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`, + }), + [game.unlockedAchievementCount, game.achievementCount] ); - // For logo, check if logoImageUrl exists (similar to game details page) const logoImage = game.logoImageUrl; return ( - <> - -
- -
- {logoImage ? ( - {game.title} +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + ) : ( -

{game.title}

+ )} + + {formatPlayTime(game.playTimeInMilliseconds)} +
+ +
-
- {/* Achievements section */} - {(game.achievementCount ?? 0) > 0 && ( -
-
-
- - - {game.unlockedAchievementCount ?? 0} /{" "} - {game.achievementCount ?? 0} - -
- - {Math.round( - ((game.unlockedAchievementCount ?? 0) / - (game.achievementCount ?? 1)) * - 100 - )} - % +
+ {logoImage ? ( + {game.title} + ) : ( +

{game.title}

+ )} +
+ +
+ {/* Achievements section */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0}
-
-
-
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % +
- )} - - -
+ } + + if (isGameRunning) { + return ( + <> + + {t("close")} + + ); + } + + if (game.executablePath) { + return ( + <> + + {t("play")} + + ); + } + + return ( + <> + + {t("download")} + + ); + })()} +
- - - +
+ ); -} +}); diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index 7dbdff95..1b5b7afa 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,7 +1,7 @@ import { LibraryGame } from "@types"; import { useFormat } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { useCallback, useState } from "react"; +import { useCallback, memo } from "react"; import { buildGameDetailsPath } from "@renderer/helpers"; import { ClockIcon, @@ -10,30 +10,30 @@ import { TrophyIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { Tooltip } from "react-tooltip"; import { useTranslation } from "react-i18next"; -import { GameContextMenu } from "@renderer/components"; import "./library-game-card.scss"; interface LibraryGameCardProps { game: LibraryGame; onMouseEnter: () => void; onMouseLeave: () => void; + onContextMenu: ( + game: LibraryGame, + position: { x: number; y: number } + ) => void; + onShowTooltip?: (gameId: string) => void; + onHideTooltip?: () => void; } -export function LibraryGameCard({ +export const LibraryGameCard = memo(function LibraryGameCard({ game, onMouseEnter, onMouseLeave, + onContextMenu, }: Readonly) { const { t } = useTranslation("library"); const { numberFormatter } = useFormat(); const navigate = useNavigate(); - const [isTooltipHovered, setIsTooltipHovered] = useState(false); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - }>({ visible: false, position: { x: 0, y: 0 } }); const formatPlayTime = useCallback( (playTimeInMilliseconds = 0, isShort = false) => { @@ -60,30 +60,23 @@ export function LibraryGameCard({ navigate(buildGameDetailsPath(game)); }; - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleContextMenuClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(game, { x: e.clientX, y: e.clientY }); + }, + [game, onContextMenu] + ); - setContextMenu({ - visible: true, - position: { x: e.clientX, y: e.clientY }, - }); - }; - - const handleMenuButtonClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setContextMenu({ - visible: true, - position: { - x: e.currentTarget.getBoundingClientRect().right, - y: e.currentTarget.getBoundingClientRect().bottom, - }, - }); - }; - - const handleCloseContextMenu = () => { - setContextMenu({ visible: false, position: { x: 0, y: 0 } }); - }; + const handleMenuButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onContextMenu(game, { x: rect.right, y: rect.bottom }); + }, + [game, onContextMenu] + ); const coverImage = game.coverImageUrl ?? @@ -93,110 +86,85 @@ export function LibraryGameCard({ undefined; return ( - <> - +
- {game.title} - - setIsTooltipHovered(true)} - afterHide={() => setIsTooltipHovered(false)} + {/* Achievements section - shown on hover */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )} +
+ + {game.title} - - + ); -} +}); diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 8b4ad6a9..4f1e7ed2 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; +import { LibraryGame } from "@types"; +import { GameContextMenu } from "@renderer/components"; +import VirtualList from "rc-virtual-list"; import { LibraryGameCard } from "./library-game-card"; -// detailed view removed — keep file if needed later import { LibraryGameCardLarge } from "./library-game-card-large"; import { ViewOptions, ViewMode } from "./view-options"; import { FilterOptions, FilterOption } from "./filter-options"; @@ -19,6 +21,14 @@ export default function Library() { const [viewMode, setViewMode] = useState("compact"); const [filterBy, setFilterBy] = useState("all"); + const [containerHeight, setContainerHeight] = useState(800); + const [contextMenu, setContextMenu] = useState<{ + game: LibraryGame | null; + visible: boolean; + position: { x: number; y: number }; + }>({ game: null, visible: false, position: { x: 0, y: 0 } }); + + const containerRef = useRef(null); const searchQuery = useAppSelector((state) => state.library.searchQuery); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); @@ -47,13 +57,37 @@ export default function Library() { }; }, [dispatch, t, updateLibrary]); - const handleOnMouseEnterGameCard = () => { - // Optional: pause animations if needed - }; + useEffect(() => { + const updateHeight = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerHeight(window.innerHeight - rect.top); + } + }; - const handleOnMouseLeaveGameCard = () => { + updateHeight(); + window.addEventListener("resize", updateHeight); + return () => window.removeEventListener("resize", updateHeight); + }, []); + + const handleOnMouseEnterGameCard = useCallback(() => { + // Optional: pause animations if needed + }, []); + + const handleOnMouseLeaveGameCard = useCallback(() => { // Optional: resume animations if needed - }; + }, []); + + const handleOpenContextMenu = useCallback( + (game: LibraryGame, position: { x: number; y: number }) => { + setContextMenu({ game, visible: true, position }); + }, + [] + ); + + const handleCloseContextMenu = useCallback(() => { + setContextMenu({ game: null, visible: false, position: { x: 0, y: 0 } }); + }, []); const filteredLibrary = useMemo(() => { let filtered; @@ -102,21 +136,33 @@ export default function Library() { }); }, [library, filterBy, searchQuery]); - // No sorting for now — rely on filteredLibrary const sortedLibrary = filteredLibrary; - // Calculate counts for filters - const allGamesCount = library.length; - const favouritedCount = library.filter((game) => game.favorite).length; - const newGamesCount = library.filter( - (game) => (game.playTimeInMilliseconds || 0) === 0 - ).length; - const top10Count = Math.min(10, library.length); + const filterCounts = useMemo(() => { + const allGamesCount = library.length; + let favouritedCount = 0; + let newGamesCount = 0; + + for (const game of library) { + if (game.favorite) favouritedCount++; + if ((game.playTimeInMilliseconds || 0) === 0) newGamesCount++; + } + + return { + allGamesCount, + favouritedCount, + newGamesCount, + top10Count: Math.min(10, allGamesCount), + }; + }, [library]); const hasGames = library.length > 0; + const itemHeight = + viewMode === "large" ? 200 : viewMode === "grid" ? 240 : 180; + return ( -
+
{hasGames && (
@@ -124,10 +170,10 @@ export default function Library() {
@@ -148,17 +194,38 @@ export default function Library() {
)} - {hasGames && viewMode === "large" && ( + {hasGames && sortedLibrary.length > 50 && viewMode === "large" && (
- {sortedLibrary.map((game) => ( - - ))} + `${game.shop}-${game.objectId}`} + > + {(game) => ( + + )} +
)} + {hasGames && + (sortedLibrary.length <= 50 || viewMode !== "large") && + viewMode === "large" && ( +
+ {sortedLibrary.map((game) => ( + + ))} +
+ )} + {hasGames && viewMode !== "large" && (
    {sortedLibrary.map((game) => ( @@ -170,11 +237,21 @@ export default function Library() { game={game} onMouseEnter={handleOnMouseEnterGameCard} onMouseLeave={handleOnMouseLeaveGameCard} + onContextMenu={handleOpenContextMenu} /> ))}
)} + + {contextMenu.game && ( + + )}
); } From 196413ee280ffbfdd14ffe3adaf2f1ff83562e97 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 8 Nov 2025 14:48:42 +0200 Subject: [PATCH 16/36] fix: duplicated lines --- src/renderer/src/hooks/index.ts | 1 + src/renderer/src/hooks/use-game-card.ts | 66 +++++++++++++++++++ .../pages/library/library-game-card-large.tsx | 58 +++------------- .../src/pages/library/library-game-card.tsx | 60 +++-------------- 4 files changed, 83 insertions(+), 102 deletions(-) create mode 100644 src/renderer/src/hooks/use-game-card.ts diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 73733e2b..23190def 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -6,3 +6,4 @@ export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; export * from "./use-feature"; +export * from "./use-game-card"; diff --git a/src/renderer/src/hooks/use-game-card.ts b/src/renderer/src/hooks/use-game-card.ts new file mode 100644 index 00000000..98987189 --- /dev/null +++ b/src/renderer/src/hooks/use-game-card.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useFormat } from "./use-format"; +import { useTranslation } from "react-i18next"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { LibraryGame } from "@types"; + +export function useGameCard( + game: LibraryGame, + onContextMenu: (game: LibraryGame, position: { x: number; y: number }) => void +) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = useCallback(() => { + navigate(buildGameDetailsPath(game)); + }, [navigate, game]); + + const handleContextMenuClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(game, { x: e.clientX, y: e.clientY }); + }, + [game, onContextMenu] + ); + + const handleMenuButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onContextMenu(game, { x: rect.right, y: rect.bottom }); + }, + [game, onContextMenu] + ); + + return { + formatPlayTime, + handleCardClick, + handleContextMenuClick, + handleMenuButtonClick, + }; +} diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 4cb54977..5c0e54c4 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,7 +1,5 @@ import { LibraryGame } from "@types"; -import { useDownload, useFormat } from "@renderer/hooks"; -import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath } from "@renderer/helpers"; +import { useDownload, useGameCard } from "@renderer/hooks"; import { PlayIcon, DownloadIcon, @@ -12,9 +10,8 @@ import { XIcon, } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; -import { useCallback, memo, useMemo } from "react"; +import { memo, useMemo } from "react"; import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions"; -import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { logger } from "@renderer/logger"; import "./library-game-card-large.scss"; @@ -39,38 +36,17 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ onContextMenu, }: Readonly) { const { t } = useTranslation("library"); - const { numberFormatter } = useFormat(); - const navigate = useNavigate(); const { lastPacket } = useDownload(); + const { + formatPlayTime, + handleCardClick, + handleContextMenuClick, + handleMenuButtonClick, + } = useGameCard(game, onContextMenu); const isGameDownloading = game?.download?.status === "active" && lastPacket?.gameId === game?.id; - const formatPlayTime = useCallback( - (playTimeInMilliseconds = 0, isShort = false) => { - const minutes = playTimeInMilliseconds / 60000; - - if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { - return t(isShort ? "amount_minutes_short" : "amount_minutes", { - amount: minutes.toFixed(0), - }); - } - - const hours = minutes / 60; - const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; - const hoursAmount = isShort - ? Math.floor(hours) - : numberFormatter.format(hours); - - return t(hoursKey, { amount: hoursAmount }); - }, - [numberFormatter, t] - ); - - const handleCardClick = () => { - navigate(buildGameDetailsPath(game)); - }; - const { handlePlayGame, handleOpenDownloadOptions, @@ -101,24 +77,6 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ } }; - const handleContextMenuClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu(game, { x: e.clientX, y: e.clientY }); - }, - [game, onContextMenu] - ); - - const handleMenuButtonClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - onContextMenu(game, { x: rect.right, y: rect.bottom }); - }, - [game, onContextMenu] - ); - const backgroundImage = useMemo( () => getImageWithCustomPriority( diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index 1b5b7afa..64053155 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,16 +1,12 @@ import { LibraryGame } from "@types"; -import { useFormat } from "@renderer/hooks"; -import { useNavigate } from "react-router-dom"; -import { useCallback, memo } from "react"; -import { buildGameDetailsPath } from "@renderer/helpers"; +import { useGameCard } from "@renderer/hooks"; +import { memo } from "react"; import { ClockIcon, AlertFillIcon, ThreeBarsIcon, TrophyIcon, } from "@primer/octicons-react"; -import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { useTranslation } from "react-i18next"; import "./library-game-card.scss"; interface LibraryGameCardProps { @@ -31,52 +27,12 @@ export const LibraryGameCard = memo(function LibraryGameCard({ onMouseLeave, onContextMenu, }: Readonly) { - const { t } = useTranslation("library"); - const { numberFormatter } = useFormat(); - const navigate = useNavigate(); - - const formatPlayTime = useCallback( - (playTimeInMilliseconds = 0, isShort = false) => { - const minutes = playTimeInMilliseconds / 60000; - - if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { - return t(isShort ? "amount_minutes_short" : "amount_minutes", { - amount: minutes.toFixed(0), - }); - } - - const hours = minutes / 60; - const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; - const hoursAmount = isShort - ? Math.floor(hours) - : numberFormatter.format(hours); - - return t(hoursKey, { amount: hoursAmount }); - }, - [numberFormatter, t] - ); - - const handleCardClick = () => { - navigate(buildGameDetailsPath(game)); - }; - - const handleContextMenuClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu(game, { x: e.clientX, y: e.clientY }); - }, - [game, onContextMenu] - ); - - const handleMenuButtonClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - onContextMenu(game, { x: rect.right, y: rect.bottom }); - }, - [game, onContextMenu] - ); + const { + formatPlayTime, + handleCardClick, + handleContextMenuClick, + handleMenuButtonClick, + } = useGameCard(game, onContextMenu); const coverImage = game.coverImageUrl ?? From cf48627a8d1db8f71e9124748f271f8f2908f269 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 8 Nov 2025 14:51:10 +0200 Subject: [PATCH 17/36] fix: extracter ternary operation --- src/renderer/src/pages/library/library.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 4f1e7ed2..d9ef1c3c 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -158,8 +158,14 @@ export default function Library() { const hasGames = library.length > 0; - const itemHeight = - viewMode === "large" ? 200 : viewMode === "grid" ? 240 : 180; + let itemHeight: number; + if (viewMode === "large") { + itemHeight = 200; + } else if (viewMode === "grid") { + itemHeight = 240; + } else { + itemHeight = 180; + } return (
From 482d9b2f96bada5fb42c35e7b07e2189912bd22b Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 8 Nov 2025 15:14:12 +0200 Subject: [PATCH 18/36] fix: ensure consistent custom sound detection across main and renderer processes --- src/main/services/notifications/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index f1df1478..926ba47a 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -46,7 +46,7 @@ async function getAchievementSoundPath(): Promise { const allThemes = await themesSublevel.values().all(); const activeTheme = allThemes.find((theme) => theme.isActive); - if (activeTheme) { + if (activeTheme?.hasCustomSound) { const themeSoundPath = getThemeSoundPath(activeTheme.id); if (themeSoundPath) { return themeSoundPath; From 011559b499a16a31a1f102d3b4de7cd1e1119e41 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 8 Nov 2025 19:24:43 +0200 Subject: [PATCH 19/36] fix: removed VirtualList component from large view --- src/renderer/src/pages/library/library.tsx | 67 ++++------------------ 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index d9ef1c3c..323db73e 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -1,11 +1,10 @@ -import { useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { LibraryGame } from "@types"; import { GameContextMenu } from "@renderer/components"; -import VirtualList from "rc-virtual-list"; import { LibraryGameCard } from "./library-game-card"; import { LibraryGameCardLarge } from "./library-game-card-large"; import { ViewOptions, ViewMode } from "./view-options"; @@ -21,14 +20,12 @@ export default function Library() { const [viewMode, setViewMode] = useState("compact"); const [filterBy, setFilterBy] = useState("all"); - const [containerHeight, setContainerHeight] = useState(800); const [contextMenu, setContextMenu] = useState<{ game: LibraryGame | null; visible: boolean; position: { x: number; y: number }; }>({ game: null, visible: false, position: { x: 0, y: 0 } }); - - const containerRef = useRef(null); + const searchQuery = useAppSelector((state) => state.library.searchQuery); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); @@ -57,19 +54,6 @@ export default function Library() { }; }, [dispatch, t, updateLibrary]); - useEffect(() => { - const updateHeight = () => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - setContainerHeight(window.innerHeight - rect.top); - } - }; - - updateHeight(); - window.addEventListener("resize", updateHeight); - return () => window.removeEventListener("resize", updateHeight); - }, []); - const handleOnMouseEnterGameCard = useCallback(() => { // Optional: pause animations if needed }, []); @@ -158,17 +142,8 @@ export default function Library() { const hasGames = library.length > 0; - let itemHeight: number; - if (viewMode === "large") { - itemHeight = 200; - } else if (viewMode === "grid") { - itemHeight = 240; - } else { - itemHeight = 180; - } - return ( -
+
{hasGames && (
@@ -200,38 +175,18 @@ export default function Library() {
)} - {hasGames && sortedLibrary.length > 50 && viewMode === "large" && ( + {hasGames && viewMode === "large" && (
- `${game.shop}-${game.objectId}`} - > - {(game) => ( - - )} - + {sortedLibrary.map((game) => ( + + ))}
)} - {hasGames && - (sortedLibrary.length <= 50 || viewMode !== "large") && - viewMode === "large" && ( -
- {sortedLibrary.map((game) => ( - - ))} -
- )} - {hasGames && viewMode !== "large" && (
    {sortedLibrary.map((game) => ( From 65e2bb38a095b284e09923a96a857d5e5ca3a02c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 8 Nov 2025 19:26:13 +0200 Subject: [PATCH 20/36] ci: formatting --- src/renderer/src/pages/library/library.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 323db73e..7167809d 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -25,7 +25,7 @@ export default function Library() { visible: boolean; position: { x: number; y: number }; }>({ game: null, visible: false, position: { x: 0, y: 0 } }); - + const searchQuery = useAppSelector((state) => state.library.searchQuery); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); From 3daf28c8825250c6f9ca15138d2ac7e63590e782 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 9 Nov 2025 04:19:52 +0200 Subject: [PATCH 21/36] fix: handling exception and ESLint issues --- src/main/events/themes/import-theme-sound-from-store.ts | 4 ++++ src/renderer/src/pages/settings/settings-general.tsx | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts index 588db6f5..135353b2 100644 --- a/src/main/events/themes/import-theme-sound-from-store.ts +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -46,6 +46,10 @@ const importThemeSoundFromStore = async ( logger.log(`Successfully imported sound for theme ${themeName}`); return; } catch (error) { + logger.error( + `Failed to import ${format} sound for theme ${themeName}`, + error + ); continue; } } diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index 172b3291..c57fb5b3 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -361,16 +361,16 @@ export function SettingsGeneral() { } const volumePercent = Math.min( 100, - Math.max(0, parseInt(value, 10)) + Math.max(0, Number.parseInt(value, 10)) ); - if (!isNaN(volumePercent)) { + if (!Number.isNaN(volumePercent)) { handleVolumeChange(volumePercent); } }} onBlur={(e) => { if ( e.target.value === "" || - isNaN(parseInt(e.target.value, 10)) + Number.isNaN(Number.parseInt(e.target.value, 10)) ) { handleVolumeChange(0); } From e272470a7b6549333b6a379f6950911bf995f19f Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 9 Nov 2025 15:28:52 +0200 Subject: [PATCH 22/36] feat: using theme name for folder instead themeid --- .../themes/copy-theme-achievement-sound.ts | 2 +- .../events/themes/get-theme-sound-data-url.ts | 4 +- .../events/themes/get-theme-sound-path.ts | 4 +- .../themes/import-theme-sound-from-store.ts | 2 +- .../themes/remove-theme-achievement-sound.ts | 31 ++++++---- src/main/helpers/index.ts | 57 +++++++++++++++---- src/main/services/notifications/index.ts | 5 +- 7 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/main/events/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts index aec22cb2..e2c927fd 100644 --- a/src/main/events/themes/copy-theme-achievement-sound.ts +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -18,7 +18,7 @@ const copyThemeAchievementSound = async ( throw new Error("Theme not found"); } - const themeDir = getThemePath(themeId); + const themeDir = getThemePath(themeId, theme.name); if (!fs.existsSync(themeDir)) { fs.mkdirSync(themeDir, { recursive: true }); diff --git a/src/main/events/themes/get-theme-sound-data-url.ts b/src/main/events/themes/get-theme-sound-data-url.ts index b9ace306..a93538dd 100644 --- a/src/main/events/themes/get-theme-sound-data-url.ts +++ b/src/main/events/themes/get-theme-sound-data-url.ts @@ -1,5 +1,6 @@ import { registerEvent } from "../register-event"; import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; import fs from "node:fs"; import path from "node:path"; import { logger } from "@main/services"; @@ -9,7 +10,8 @@ const getThemeSoundDataUrl = async ( themeId: string ): Promise => { try { - const soundPath = getThemeSoundPath(themeId); + const theme = await themesSublevel.get(themeId); + const soundPath = getThemeSoundPath(themeId, theme?.name); if (!soundPath || !fs.existsSync(soundPath)) { return null; diff --git a/src/main/events/themes/get-theme-sound-path.ts b/src/main/events/themes/get-theme-sound-path.ts index 37783949..11658c6a 100644 --- a/src/main/events/themes/get-theme-sound-path.ts +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -1,11 +1,13 @@ import { registerEvent } from "../register-event"; import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; const getThemeSoundPathEvent = async ( _event: Electron.IpcMainInvokeEvent, themeId: string ): Promise => { - return getThemeSoundPath(themeId); + const theme = await themesSublevel.get(themeId); + return getThemeSoundPath(themeId, theme?.name); }; registerEvent("getThemeSoundPath", getThemeSoundPathEvent); diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts index 135353b2..66da6cb3 100644 --- a/src/main/events/themes/import-theme-sound-from-store.ts +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -28,7 +28,7 @@ const importThemeSoundFromStore = async ( timeout: 10000, }); - const themeDir = getThemePath(themeId); + const themeDir = getThemePath(themeId, theme.name); if (!fs.existsSync(themeDir)) { fs.mkdirSync(themeDir, { recursive: true }); diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts index 4ca738f3..6c17bb6f 100644 --- a/src/main/events/themes/remove-theme-achievement-sound.ts +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -2,6 +2,7 @@ import { registerEvent } from "../register-event"; import fs from "node:fs"; import { getThemePath } from "@main/helpers"; import { themesSublevel } from "@main/level"; +import { THEMES_PATH } from "@main/constants"; import path from "node:path"; const removeThemeAchievementSound = async ( @@ -13,19 +14,27 @@ const removeThemeAchievementSound = async ( throw new Error("Theme not found"); } - const themeDir = getThemePath(themeId); + const themeDir = getThemePath(themeId, theme.name); + const legacyThemeDir = path.join(THEMES_PATH, themeId); - if (!fs.existsSync(themeDir)) { - return; - } - - const formats = ["wav", "mp3", "ogg", "m4a"]; - - for (const format of formats) { - const soundPath = path.join(themeDir, `achievement.${format}`); - if (fs.existsSync(soundPath)) { - await fs.promises.unlink(soundPath); + const removeFromDir = async (dir: string) => { + if (!fs.existsSync(dir)) { + return; } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + await fs.promises.unlink(soundPath); + } + } + }; + + await removeFromDir(themeDir); + if (themeDir !== legacyThemeDir) { + await removeFromDir(legacyThemeDir); } await themesSublevel.put(themeId, { diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index ae19fbdb..550db81f 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -38,23 +38,56 @@ export const normalizePath = (str: string) => export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; -export const getThemePath = (themeId: string) => - path.join(THEMES_PATH, themeId); +const sanitizeFolderName = (name: string): string => { + return name + .toLowerCase() + .replace(/[^a-z0-9-_\s]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +}; -export const getThemeSoundPath = (themeId: string): string | null => { - const themeDir = getThemePath(themeId); +export const getThemePath = (themeId: string, themeName?: string): string => { + if (themeName) { + const sanitizedName = sanitizeFolderName(themeName); + if (sanitizedName) { + return path.join(THEMES_PATH, sanitizedName); + } + } + return path.join(THEMES_PATH, themeId); +}; + +export const getThemeSoundPath = ( + themeId: string, + themeName?: string +): string | null => { + const themeDir = getThemePath(themeId, themeName); + const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null; + + const checkDir = (dir: string): string | null => { + if (!fs.existsSync(dir)) { + return null; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + return soundPath; + } + } - if (!fs.existsSync(themeDir)) { return null; + }; + + const soundPath = checkDir(themeDir); + if (soundPath) { + return soundPath; } - const formats = ["wav", "mp3", "ogg", "m4a"]; - - for (const format of formats) { - const soundPath = path.join(themeDir, `achievement.${format}`); - if (fs.existsSync(soundPath)) { - return soundPath; - } + if (legacyThemeDir) { + return checkDir(legacyThemeDir); } return null; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index 926ba47a..b8ff480c 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -47,7 +47,10 @@ async function getAchievementSoundPath(): Promise { const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.hasCustomSound) { - const themeSoundPath = getThemeSoundPath(activeTheme.id); + const themeSoundPath = getThemeSoundPath( + activeTheme.id, + activeTheme.name + ); if (themeSoundPath) { return themeSoundPath; } From d54ff9a949c1fc4be63f02eda3fd4b917f8149ca Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 9 Nov 2025 15:34:24 +0200 Subject: [PATCH 23/36] fix: eslint issues --- src/main/helpers/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 550db81f..664dbd78 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -33,7 +33,7 @@ export const isPortableVersion = () => { }; export const normalizePath = (str: string) => - path.posix.normalize(str).replace(/\\/g, "/"); + path.posix.normalize(str).replaceAll("\\", "/"); export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; @@ -41,10 +41,10 @@ export const addTrailingSlash = (str: string) => const sanitizeFolderName = (name: string): string => { return name .toLowerCase() - .replace(/[^a-z0-9-_\s]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); + .replaceAll(/[^a-z0-9-_\s]/g, "") + .replaceAll(/\s+/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/(^-|-$)/g, ""); }; export const getThemePath = (themeId: string, themeName?: string): string => { From 46df34e8a584029366d264865548672907bc24f2 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Mon, 10 Nov 2025 22:20:44 +0000 Subject: [PATCH 24/36] feat: improving library --- src/locales/en/translation.json | 5 +- src/locales/es/translation.json | 22 ++++ src/locales/pt-BR/translation.json | 22 ++++ src/locales/ru/translation.json | 22 ++++ .../src/pages/library/filter-options.scss | 86 ++++++------ .../src/pages/library/filter-options.tsx | 122 ++++++++++++------ .../library/library-game-card-large.scss | 76 ----------- .../pages/library/library-game-card-large.tsx | 98 +------------- .../src/pages/library/library-game-card.scss | 49 +------ .../src/pages/library/library-game-card.tsx | 12 -- src/renderer/src/pages/library/library.scss | 5 + src/renderer/src/pages/library/library.tsx | 110 ++++++++-------- 12 files changed, 257 insertions(+), 372 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index cb2473eb..bcd774ea 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -719,9 +719,8 @@ "amount_minutes_short": "{{amount}}m", "manual_playtime_tooltip": "This playtime has been manually updated", "all_games": "All Games", - "favourited_games": "Favourited", - "new_games": "New Games", - "top_10": "Top 10" + "recently_played": "Recently Played", + "favorites": "Favorites" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index c7e9d13e..ad08777a 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catálogo", + "library": "Librería", "downloads": "Descargas", "settings": "Ajustes", "my_library": "Mi Librería", @@ -716,5 +717,26 @@ "hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!", "learn_more": "Descubrir más", "debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus" + }, + "library": { + "library": "Librería", + "play": "Jugar", + "download": "Descargar", + "downloading": "Descargando", + "game": "juego", + "games": "juegos", + "grid_view": "Vista de cuadrícula", + "compact_view": "Vista compacta", + "large_view": "Vista grande", + "no_games_title": "Tu librería está vacía", + "no_games_description": "Agregá juegos del catálogo o descargalos para comenzar", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente", + "all_games": "Todos los Juegos", + "recently_played": "Jugados Recientemente", + "favorites": "Favoritos" } } diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 50049140..002ec720 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Downloads", "settings": "Ajustes", "my_library": "Biblioteca", @@ -731,5 +732,26 @@ "hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!", "learn_more": "Saiba mais", "debrid_description": "Baixe até 4x mais rápido com Nimbus" + }, + "library": { + "library": "Biblioteca", + "play": "Jogar", + "download": "Baixar", + "downloading": "Baixando", + "game": "jogo", + "games": "jogos", + "grid_view": "Visualização em grade", + "compact_view": "Visualização compacta", + "large_view": "Visualização grande", + "no_games_title": "Sua biblioteca está vazia", + "no_games_description": "Adicione jogos do catálogo ou baixe-os para começar", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "all_games": "Todos os Jogos", + "recently_played": "Jogados Recentemente", + "favorites": "Favoritos" } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 2e7c1504..02477701 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Каталог", + "library": "Библиотека", "downloads": "Загрузки", "settings": "Настройки", "my_library": "Библиотека", @@ -727,5 +728,26 @@ "hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!", "learn_more": "Подробнее", "debrid_description": "Скачивайте в 4 раза быстрее с Nimbus" + }, + "library": { + "library": "Библиотека", + "play": "Играть", + "download": "Скачать", + "downloading": "Скачивание", + "game": "игра", + "games": "игры", + "grid_view": "Вид сетки", + "compact_view": "Компактный вид", + "large_view": "Большой вид", + "no_games_title": "Ваша библиотека пуста", + "no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать", + "amount_hours": "{{amount}} часов", + "amount_minutes": "{{amount}} минут", + "amount_hours_short": "{{amount}}ч", + "amount_minutes_short": "{{amount}}м", + "manual_playtime_tooltip": "Время игры было обновлено вручную", + "all_games": "Все игры", + "recently_played": "Недавно сыгранные", + "favorites": "Избранное" } } diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss index 4831fd0e..25835072 100644 --- a/src/renderer/src/pages/library/filter-options.scss +++ b/src/renderer/src/pages/library/filter-options.scss @@ -1,63 +1,55 @@ @use "../../scss/globals.scss"; .library-filter-options { - &__container { + &__tabs { display: flex; - align-items: center; gap: calc(globals.$spacing-unit); - flex-wrap: wrap; + position: relative; } - &__option { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit); - padding: 8px 12px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.05); - color: rgba(255, 255, 255, 0.9); + &__tab-wrapper { + position: relative; + } + + &__tab { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2); cursor: pointer; - font-size: 12px; + font-size: 14px; font-weight: 500; - transition: all ease 0.2s; - white-space: nowrap; /* prevent label and count from wrapping */ - border: 1px solid rgba(0, 0, 0, 0.06); + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); - &:hover { - color: rgba(255, 255, 255, 0.9); - background: rgba(255, 255, 255, 0.08); - } - - &.active { - color: #000; - background: #fff; - svg, - svg * { - fill: currentColor; - color: currentColor; - } - - .library-filter-options__count { - background: #ebebeb; - color: rgba(0, 0, 0, 0.9); - } + &--active { + color: white; } } - &__label { - font-weight: 500; - white-space: nowrap; - } - - &__count { - background: rgba(255, 255, 255, 0.16); - color: rgba(255, 255, 255, 0.95); - padding: 2px 8px; - border-radius: 4px; - font-size: 12px; + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; font-weight: 600; - min-width: 24px; - text-align: center; - transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.9); + line-height: 1; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; } } diff --git a/src/renderer/src/pages/library/filter-options.tsx b/src/renderer/src/pages/library/filter-options.tsx index 572ebd35..cd22368f 100644 --- a/src/renderer/src/pages/library/filter-options.tsx +++ b/src/renderer/src/pages/library/filter-options.tsx @@ -1,61 +1,103 @@ +import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import "./filter-options.scss"; -export type FilterOption = "all" | "favourited" | "new" | "top10"; +export type FilterOption = "all" | "recently_played" | "favorites"; interface FilterOptionsProps { filterBy: FilterOption; onFilterChange: (filterBy: FilterOption) => void; allGamesCount: number; - favouritedCount: number; - newGamesCount: number; - top10Count: number; + recentlyPlayedCount: number; + favoritesCount: number; } export function FilterOptions({ filterBy, onFilterChange, allGamesCount, - favouritedCount, - newGamesCount, - top10Count, + recentlyPlayedCount, + favoritesCount, }: Readonly) { const { t } = useTranslation("library"); return ( -
    - - - - +
    +
    + + {filterBy === "all" && ( + + )} +
    +
    + + {filterBy === "recently_played" && ( + + )} +
    +
    + + {filterBy === "favorites" && ( + + )} +
    ); } diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index 212fe80a..a06de7e9 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -84,36 +84,6 @@ gap: calc(globals.$spacing-unit); } - &__menu-button { - align-self: flex-start; - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 4px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all ease 0.2s; - color: rgba(255, 255, 255, 0.95); - padding: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - opacity: 0; - transform: scale(0.9); - - &:hover { - background: rgba(0, 0, 0, 0.6); - border-color: rgba(255, 255, 255, 0.25); - transform: scale(1.05); - } - - &:active { - transform: scale(0.95); - } - } &__logo-container { flex: 1; @@ -238,50 +208,4 @@ white-space: nowrap; } - &__action-button { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 20px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - cursor: pointer; - font-size: 14px; - font-weight: 600; - transition: all ease 0.2s; - flex: 0 0 auto; - - &:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.3); - transform: scale(1.05); - } - - &:active { - transform: scale(0.98); - } - } - - &:hover &__menu-button { - opacity: 1; - transform: scale(1); - } - - &__action-icon--downloading { - animation: pulse 1.5s ease-in-out infinite; - } -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } } diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 5c0e54c4..c2bf6a3b 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,18 +1,11 @@ import { LibraryGame } from "@types"; -import { useDownload, useGameCard } from "@renderer/hooks"; +import { useGameCard } from "@renderer/hooks"; import { - PlayIcon, - DownloadIcon, ClockIcon, AlertFillIcon, - ThreeBarsIcon, TrophyIcon, - XIcon, } from "@primer/octicons-react"; -import { useTranslation } from "react-i18next"; import { memo, useMemo } from "react"; -import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions"; -import { logger } from "@renderer/logger"; import "./library-game-card-large.scss"; interface LibraryGameCardLargeProps { @@ -35,48 +28,12 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ game, onContextMenu, }: Readonly) { - const { t } = useTranslation("library"); - const { lastPacket } = useDownload(); const { formatPlayTime, handleCardClick, handleContextMenuClick, - handleMenuButtonClick, } = useGameCard(game, onContextMenu); - const isGameDownloading = - game?.download?.status === "active" && lastPacket?.gameId === game?.id; - - const { - handlePlayGame, - handleOpenDownloadOptions, - handleCloseGame, - isGameRunning, - } = useGameActions(game); - - const handleActionClick = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (isGameRunning) { - try { - await handleCloseGame(); - } catch (e) { - logger.error(e); - } - return; - } - try { - await handlePlayGame(); - } catch (err) { - logger.error(err); - try { - handleOpenDownloadOptions(); - } catch (e) { - logger.error(e); - } - } - }; - const backgroundImage = useMemo( () => getImageWithCustomPriority( @@ -129,14 +86,6 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ {formatPlayTime(game.playTimeInMilliseconds)}
    -
@@ -183,51 +132,6 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
)} - -
diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index 1270e2aa..8643d23c 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -109,10 +109,10 @@ &__achievements { display: flex; flex-direction: column; - opacity: 0; - transform: translateY(8px); + opacity: 1; + transform: translateY(0); transition: all ease 0.2s; - pointer-events: none; + pointer-events: auto; width: 100%; } @@ -204,53 +204,12 @@ } } - &__menu-button { - 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; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all ease 0.2s; - color: rgba(255, 255, 255, 0.8); - padding: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - opacity: 0; - transform: scale(0.9); - - &:hover { - background: rgba(0, 0, 0, 0.6); - border-color: rgba(255, 255, 255, 0.25); - transform: scale(1.05); - } - - &:active { - transform: scale(0.95); - } - } - - &__wrapper:hover &__action-button, - &__wrapper:hover &__menu-button { + &__wrapper:hover &__action-button { opacity: 1; transform: scale(1); } - &__wrapper:hover &__achievements { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - &__action-icon { - &--downloading { - animation: pulse 1.5s ease-in-out infinite; - } - } &__game-image { object-fit: cover; diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index 64053155..39cce681 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -4,7 +4,6 @@ import { memo } from "react"; import { ClockIcon, AlertFillIcon, - ThreeBarsIcon, TrophyIcon, } from "@primer/octicons-react"; import "./library-game-card.scss"; @@ -31,7 +30,6 @@ export const LibraryGameCard = memo(function LibraryGameCard({ formatPlayTime, handleCardClick, handleContextMenuClick, - handleMenuButtonClick, } = useGameCard(game, onContextMenu); const coverImage = @@ -69,18 +67,8 @@ export const LibraryGameCard = memo(function LibraryGameCard({ {formatPlayTime(game.playTimeInMilliseconds, true)}
- -
- {/* Achievements section - shown on hover */} {(game.achievementCount ?? 0) > 0 && (
diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss index 40688084..ffc68b83 100644 --- a/src/renderer/src/pages/library/library.scss +++ b/src/renderer/src/pages/library/library.scss @@ -38,12 +38,17 @@ align-items: center; justify-content: space-between; width: 100%; + position: relative; } &__controls-left { display: flex; align-items: center; gap: calc(globals.$spacing-unit); + flex: 1; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + margin-right: calc(globals.$spacing-unit * 2); } &__controls-right { diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 7167809d..86afb549 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, useCallback } from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; @@ -77,23 +78,12 @@ export default function Library() { let filtered; switch (filterBy) { - case "favourited": + case "recently_played": + filtered = library.filter((game) => game.lastTimePlayed !== null); + break; + case "favorites": filtered = library.filter((game) => game.favorite); break; - case "new": - filtered = library.filter( - (game) => (game.playTimeInMilliseconds || 0) === 0 - ); - break; - case "top10": - filtered = library - .slice() - .sort( - (a, b) => - (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) - ) - .slice(0, 10); - break; case "all": default: filtered = library; @@ -124,19 +114,18 @@ export default function Library() { const filterCounts = useMemo(() => { const allGamesCount = library.length; - let favouritedCount = 0; - let newGamesCount = 0; + let recentlyPlayedCount = 0; + let favoritesCount = 0; for (const game of library) { - if (game.favorite) favouritedCount++; - if ((game.playTimeInMilliseconds || 0) === 0) newGamesCount++; + if (game.lastTimePlayed !== null) recentlyPlayedCount++; + if (game.favorite) favoritesCount++; } return { allGamesCount, - favouritedCount, - newGamesCount, - top10Count: Math.min(10, allGamesCount), + recentlyPlayedCount, + favoritesCount, }; }, [library]); @@ -152,9 +141,8 @@ export default function Library() { filterBy={filterBy} onFilterChange={setFilterBy} allGamesCount={filterCounts.allGamesCount} - favouritedCount={filterCounts.favouritedCount} - newGamesCount={filterCounts.newGamesCount} - top10Count={filterCounts.top10Count} + recentlyPlayedCount={filterCounts.recentlyPlayedCount} + favoritesCount={filterCounts.favoritesCount} />
@@ -175,34 +163,52 @@ export default function Library() {
)} - {hasGames && viewMode === "large" && ( -
- {sortedLibrary.map((game) => ( - - ))} -
- )} - - {hasGames && viewMode !== "large" && ( -
    - {sortedLibrary.map((game) => ( -
  • + {viewMode === "large" && ( + - -
  • - ))} -
+ {sortedLibrary.map((game) => ( + + ))} + + )} + + {viewMode !== "large" && ( + + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
    + )} + )} {contextMenu.game && ( From 399669a94cfd670502ccfd281379cf55d9588098 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Mon, 10 Nov 2025 22:55:17 +0000 Subject: [PATCH 25/36] feat: adding slider to achievement sound --- src/locales/en/translation.json | 4 + src/locales/es/translation.json | 6 + src/locales/pt-BR/translation.json | 6 + src/locales/ru/translation.json | 6 + .../themes/copy-theme-achievement-sound.ts | 1 + .../themes/remove-theme-achievement-sound.ts | 1 + .../src/pages/settings/settings-general.scss | 187 ++++++++++++------ .../src/pages/settings/settings-general.tsx | 101 +++------- .../src/pages/theme-editor/theme-editor.scss | 20 +- .../src/pages/theme-editor/theme-editor.tsx | 68 +++++-- src/types/theme.types.ts | 1 + 11 files changed, 233 insertions(+), 168 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 30b165ae..71631f91 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -562,6 +562,10 @@ "change_achievement_sound": "Change achievement sound", "remove_achievement_sound": "Remove achievement sound", "preview_sound": "Preview sound", + "select": "Select", + "preview": "Preview", + "remove": "Remove", + "no_sound_file_selected": "No sound file selected", "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index c7e9d13e..12c19d01 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -542,6 +542,12 @@ "platinum": "Platino", "hidden": "Oculto", "test_notification": "Probar notificación", + "achievement_sound_volume": "Volumen del sonido de logro", + "select_achievement_sound": "Seleccionar sonido de logro", + "select": "Seleccionar", + "preview": "Vista previa", + "remove": "Remover", + "no_sound_file_selected": "No se seleccionó ningún archivo de sonido", "notification_preview": "Probar notificación de logro", "debrid": "Debrid", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 50049140..90346b22 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -541,6 +541,12 @@ "platinum": "Platina", "hidden": "Oculta", "test_notification": "Testar notificação", + "achievement_sound_volume": "Volume do som de conquista", + "select_achievement_sound": "Selecionar som de conquista", + "select": "Selecionar", + "preview": "Reproduzir", + "remove": "Remover", + "no_sound_file_selected": "Nenhum arquivo de som selecionado", "notification_preview": "Prévia da Notificação de Conquistas", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 2e7c1504..f210d96f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -555,6 +555,12 @@ "platinum": "Платиновый", "hidden": "Скрытый", "test_notification": "Тестовое уведомление", + "achievement_sound_volume": "Громкость звука достижения", + "select_achievement_sound": "Выбрать звук достижения", + "select": "Выбрать", + "preview": "Предпросмотр", + "remove": "Удалить", + "no_sound_file_selected": "Файл звука не выбран", "notification_preview": "Предварительный просмотр уведомления о достижении", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", diff --git a/src/main/events/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts index aec22cb2..2ec10198 100644 --- a/src/main/events/themes/copy-theme-achievement-sound.ts +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -32,6 +32,7 @@ const copyThemeAchievementSound = async ( await themesSublevel.put(themeId, { ...theme, hasCustomSound: true, + originalSoundPath: sourcePath, updatedAt: new Date(), }); }; diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts index 4ca738f3..16500a11 100644 --- a/src/main/events/themes/remove-theme-achievement-sound.ts +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -31,6 +31,7 @@ const removeThemeAchievementSound = async ( await themesSublevel.put(themeId, { ...theme, hasCustomSound: false, + originalSoundPath: undefined, updatedAt: new Date(), }); }; diff --git a/src/renderer/src/pages/settings/settings-general.scss b/src/renderer/src/pages/settings/settings-general.scss index 58004362..8a6a0ac1 100644 --- a/src/renderer/src/pages/settings/settings-general.scss +++ b/src/renderer/src/pages/settings/settings-general.scss @@ -21,7 +21,7 @@ &__volume-control { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; label { font-size: 14px; @@ -29,88 +29,147 @@ } } - &__volume-input-wrapper { + &__volume-slider-wrapper { display: flex; align-items: center; - gap: 4px; - } - - &__volume-input-container { + gap: 8px; + width: 200px; position: relative; - display: flex; - align-items: center; - background: globals.$dark-background-color; - border: 1px solid globals.$border-color; - padding: 8px 8px; - border-radius: 4px; - transition: border-color 0.2s; - - &:focus-within { - border-color: rgba(255, 255, 255, 0.5); - color: globals.$muted-color; - } - - input[type="number"] { - width: 30px; - background: transparent; - border: none; - color: globals.$muted-color; - font-size: 14px; - text-align: center; - - &:focus { - outline: none; - } - - &::-webkit-inner-spin-button, - &::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; - } - - appearance: textfield; - -moz-appearance: textfield; - } + --volume-percent: 0%; } - &__volume-input-unit { - font-size: 14px; + &__volume-icon { color: globals.$muted-color; - pointer-events: none; - user-select: none; + flex-shrink: 0; } - &__volume-input-buttons { - display: flex; - flex-direction: column; - gap: 2px; + &__volume-value { + font-size: 14px; + color: globals.$body-color; + font-weight: 500; + min-width: 40px; + text-align: right; + flex-shrink: 0; + } - button { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 16px; - background: globals.$dark-background-color; - border: 1px solid globals.$border-color; - border-radius: 2px; - color: globals.$muted-color; + &__volume-slider { + flex: 1; + height: 6px; + border-radius: 3px; + background: globals.$dark-background-color; + outline: none; + -webkit-appearance: none; + appearance: none; + cursor: pointer; + transition: background 0.2s; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: globals.$muted-color; cursor: pointer; + border: 2px solid globals.$background-color; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); transition: all 0.2s; + margin-top: -6px; &:hover { - color: globals.$muted-color; - border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4); } &:active { - background: globals.$background-color; + transform: scale(1.05); + } + } + + &::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: globals.$muted-color; + cursor: pointer; + border: 2px solid globals.$background-color; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: all 0.2s; + margin-top: -6px; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4); } - svg { - width: 12px; - height: 12px; + &:active { + transform: scale(1.05); } } + + &::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: linear-gradient( + to right, + globals.$muted-color 0%, + globals.$muted-color var(--volume-percent), + globals.$dark-background-color var(--volume-percent), + globals.$dark-background-color 100% + ); + } + + &::-moz-range-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: globals.$dark-background-color; + } + + &::-moz-range-progress { + height: 6px; + border-radius: 3px; + background: globals.$muted-color; + } + + &:focus { + outline: none; + + &::-webkit-slider-thumb { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2); + } + + &::-moz-range-thumb { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2); + } + } + + &::-ms-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: globals.$muted-color; + cursor: pointer; + border: 2px solid globals.$background-color; + } + + &::-ms-track { + width: 100%; + height: 6px; + background: transparent; + border-color: transparent; + color: transparent; + } + + &::-ms-fill-lower { + background: globals.$muted-color; + border-radius: 3px; + } + + &::-ms-fill-upper { + background: globals.$dark-background-color; + border-radius: 3px; + } } } diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index 172b3291..c81ced7d 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -19,7 +19,7 @@ import languageResources from "@locales"; import { orderBy } from "lodash-es"; import { settingsContext } from "@renderer/context"; import "./settings-general.scss"; -import { DesktopDownloadIcon } from "@primer/octicons-react"; +import { DesktopDownloadIcon, UnmuteIcon } from "@primer/octicons-react"; import { logger } from "@renderer/logger"; import { AchievementCustomNotificationPosition } from "@types"; @@ -345,81 +345,30 @@ export function SettingsGeneral() { -
    -
    - { - const value = e.target.value; - if (value === "") { - handleVolumeChange(0); - return; - } - const volumePercent = Math.min( - 100, - Math.max(0, parseInt(value, 10)) - ); - if (!isNaN(volumePercent)) { - handleVolumeChange(volumePercent); - } - }} - onBlur={(e) => { - if ( - e.target.value === "" || - isNaN(parseInt(e.target.value, 10)) - ) { - handleVolumeChange(0); - } - }} - /> - % -
    -
    - - -
    +
    + + { + const volumePercent = parseInt(e.target.value, 10); + if (!isNaN(volumePercent)) { + handleVolumeChange(volumePercent); + } + }} + className="settings-general__volume-slider" + style={ + { + "--volume-percent": `${form.achievementSoundVolume}%`, + } as React.CSSProperties + } + /> + + {form.achievementSoundVolume}% +
    )} diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss index 2d3d9067..486f694c 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -95,9 +95,8 @@ &__notification-preview { padding-top: 12px; display: flex; - flex-direction: row; + flex-direction: column; gap: 16px; - align-items: flex-start; &__select-variation { flex: inherit; @@ -118,16 +117,17 @@ gap: 8px; } - &__sound-controls { + &__sound-actions { display: flex; - flex-direction: column; + flex-direction: row; gap: 8px; - width: fit-content; + align-items: center; + } - button, - .button { - width: auto; - align-self: flex-start; - } + &__sound-actions-row { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 75df5e1e..1224d4cd 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -3,7 +3,7 @@ import "./theme-editor.scss"; import Editor from "@monaco-editor/react"; import { AchievementCustomNotificationPosition, Theme } from "@types"; import { useSearchParams } from "react-router-dom"; -import { Button, SelectField } from "@renderer/components"; +import { Button, SelectField, TextField } from "@renderer/components"; import { CheckIcon, UploadIcon, @@ -14,7 +14,6 @@ import { useTranslation } from "react-i18next"; import cn from "classnames"; import { injectCustomCss, - getAchievementSoundUrl, getAchievementSoundVolume, } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; @@ -36,6 +35,7 @@ export default function ThemeEditor() { const [theme, setTheme] = useState(null); const [code, setCode] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [soundPath, setSoundPath] = useState(""); const [isClosingNotifications, setIsClosingNotifications] = useState(false); @@ -71,6 +71,9 @@ export default function ThemeEditor() { if (loadedTheme) { setTheme(loadedTheme); setCode(loadedTheme.code); + if (loadedTheme.originalSoundPath) { + setSoundPath(loadedTheme.originalSoundPath); + } if (shadowRootRef) { injectCustomCss(loadedTheme.code, shadowRootRef); } @@ -130,10 +133,14 @@ export default function ThemeEditor() { }); if (filePaths && filePaths.length > 0) { - await window.electron.copyThemeAchievementSound(theme.id, filePaths[0]); + const originalPath = filePaths[0]; + await window.electron.copyThemeAchievementSound(theme.id, originalPath); const updatedTheme = await window.electron.getCustomThemeById(theme.id); if (updatedTheme) { setTheme(updatedTheme); + if (updatedTheme.originalSoundPath) { + setSoundPath(updatedTheme.originalSoundPath); + } } } }, [theme]); @@ -146,15 +153,34 @@ export default function ThemeEditor() { if (updatedTheme) { setTheme(updatedTheme); } + setSoundPath(""); }, [theme]); const handlePreviewSound = useCallback(async () => { - const soundUrl = await getAchievementSoundUrl(); + if (!theme) return; + + let soundUrl: string; + + if (theme.hasCustomSound) { + const themeSoundUrl = await window.electron.getThemeSoundDataUrl(theme.id); + if (themeSoundUrl) { + soundUrl = themeSoundUrl; + } else { + const defaultSound = (await import("@renderer/assets/audio/achievement.wav")) + .default; + soundUrl = defaultSound; + } + } else { + const defaultSound = (await import("@renderer/assets/audio/achievement.wav")) + .default; + soundUrl = defaultSound; + } + const volume = await getAchievementSoundVolume(); const audio = new Audio(soundUrl); audio.volume = volume; audio.play(); - }, []); + }, [theme]); const achievementCustomNotificationPositionOptions = useMemo(() => { return [ @@ -245,28 +271,34 @@ export default function ThemeEditor() { options={achievementCustomNotificationPositionOptions} />
    +
    -
    + - {theme?.hasCustomSound - ? t("change_achievement_sound") - : t("select_achievement_sound")} + {t("select")} + } + /> - {theme?.hasCustomSound && ( - - )} - + {theme?.hasCustomSound && ( +
    +
    -
    + )}
    diff --git a/src/types/theme.types.ts b/src/types/theme.types.ts index 94285d9c..80976ec0 100644 --- a/src/types/theme.types.ts +++ b/src/types/theme.types.ts @@ -6,6 +6,7 @@ export interface Theme { isActive: boolean; code: string; hasCustomSound?: boolean; + originalSoundPath?: string; createdAt: Date; updatedAt: Date; } From 6fc5a70722c3bea8bf147416df9ff197a66d57ee Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Mon, 10 Nov 2025 22:55:49 +0000 Subject: [PATCH 26/36] feat: adding slider to achievement sound --- .../src/pages/theme-editor/theme-editor.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 1224d4cd..3f0be9cf 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -12,10 +12,7 @@ import { } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import cn from "classnames"; -import { - injectCustomCss, - getAchievementSoundVolume, -} from "@renderer/helpers"; +import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; @@ -162,17 +159,21 @@ export default function ThemeEditor() { let soundUrl: string; if (theme.hasCustomSound) { - const themeSoundUrl = await window.electron.getThemeSoundDataUrl(theme.id); + const themeSoundUrl = await window.electron.getThemeSoundDataUrl( + theme.id + ); if (themeSoundUrl) { soundUrl = themeSoundUrl; } else { - const defaultSound = (await import("@renderer/assets/audio/achievement.wav")) - .default; + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; soundUrl = defaultSound; } } else { - const defaultSound = (await import("@renderer/assets/audio/achievement.wav")) - .default; + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; soundUrl = defaultSound; } From 7c1adb70ea21c9acb1a107dd96049e5db2ac11ea Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Mon, 10 Nov 2025 23:07:52 +0000 Subject: [PATCH 27/36] fix: fixing lint --- .../src/pages/library/library-game-card-large.scss | 2 -- .../src/pages/library/library-game-card-large.tsx | 13 +++---------- .../src/pages/library/library-game-card.scss | 2 -- .../src/pages/library/library-game-card.tsx | 13 +++---------- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index a06de7e9..8ac59112 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -84,7 +84,6 @@ gap: calc(globals.$spacing-unit); } - &__logo-container { flex: 1; display: flex; @@ -207,5 +206,4 @@ color: rgba(255, 255, 255, 0.85); white-space: nowrap; } - } diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index c2bf6a3b..42b4ab72 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,10 +1,6 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; -import { - ClockIcon, - AlertFillIcon, - TrophyIcon, -} from "@primer/octicons-react"; +import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; import { memo, useMemo } from "react"; import "./library-game-card-large.scss"; @@ -28,11 +24,8 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ game, onContextMenu, }: Readonly) { - const { - formatPlayTime, - handleCardClick, - handleContextMenuClick, - } = useGameCard(game, onContextMenu); + const { formatPlayTime, handleCardClick, handleContextMenuClick } = + useGameCard(game, onContextMenu); const backgroundImage = useMemo( () => diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index 8643d23c..ab9a9f2a 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -209,8 +209,6 @@ transform: scale(1); } - - &__game-image { object-fit: cover; border-radius: 4px; diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index 39cce681..e6f2e713 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,11 +1,7 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; import { memo } from "react"; -import { - ClockIcon, - AlertFillIcon, - TrophyIcon, -} from "@primer/octicons-react"; +import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; import "./library-game-card.scss"; interface LibraryGameCardProps { @@ -26,11 +22,8 @@ export const LibraryGameCard = memo(function LibraryGameCard({ onMouseLeave, onContextMenu, }: Readonly) { - const { - formatPlayTime, - handleCardClick, - handleContextMenuClick, - } = useGameCard(game, onContextMenu); + const { formatPlayTime, handleCardClick, handleContextMenuClick } = + useGameCard(game, onContextMenu); const coverImage = game.coverImageUrl ?? From 44b24ab63d79255ff71f0bc62edb26b838249f08 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 01:14:27 +0200 Subject: [PATCH 28/36] feat: checking updates only for games with executables --- src/main/services/download-sources-checker.ts | 8 ++++---- .../src/pages/game-details/modals/repacks-modal.tsx | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index 928e3d52..b3675829 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -101,18 +101,18 @@ export class DownloadSourcesChecker { logger.info("DownloadSourcesChecker.checkForChanges() called"); try { - // Get all installed games (excluding custom games) + // Get all installed games (excluding custom games and games without executable) const installedGames = await gamesSublevel.values().all(); const nonCustomGames = installedGames.filter( - (game: Game) => game.shop !== "custom" + (game: Game) => game.shop !== "custom" && game.executablePath ); logger.info( - `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games` + `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games with executable path` ); if (nonCustomGames.length === 0) { logger.info( - "No non-custom games found, skipping download sources check" + "No non-custom games with executable path found, skipping download sources check" ); return; } diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 91013da0..a72e1323 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -200,6 +200,8 @@ export function RepacksModal({ if (viewedRepackIds.has(repack.id)) return false; + if (!game?.executablePath) return false; + if (!lastCheckTimestamp || !repack.createdAt) { return false; } From 860030a510cdbee4ec0884ac8265889a8cef3efe Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 01:23:37 +0200 Subject: [PATCH 29/36] fix: merging conflict --- src/main/main.ts | 4 +++- src/renderer/src/features/library-slice.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/main.ts b/src/main/main.ts index 6fc2b216..1c203889 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -17,6 +17,7 @@ import { Lock, DeckyPlugin, DownloadSourcesChecker, + WSClient, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -60,7 +61,8 @@ export const loadState = async () => { // Check for new download options on startup DownloadSourcesChecker.checkForChanges(); - // WSClient.connect(); + + WSClient.connect(); }); const downloads = await downloadsSublevel diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index 9c399dbe..9e671e34 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -5,10 +5,12 @@ import type { LibraryGame } from "@types"; export interface LibraryState { value: LibraryGame[]; + searchQuery: string; } const initialState: LibraryState = { value: [], + searchQuery: "", }; export const librarySlice = createSlice({ @@ -36,6 +38,9 @@ export const librarySlice = createSlice({ game.newDownloadOptionsCount = undefined; } }, + setLibrarySearchQuery: (state, action: PayloadAction) => { + state.searchQuery = action.payload; + }, }, }); @@ -43,4 +48,5 @@ export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions, + setLibrarySearchQuery, } = librarySlice.actions; From 14eb0f8172dc8f1bea47f7f2a8d9b0253b078a7c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 01:27:24 +0200 Subject: [PATCH 30/36] reverting changes --- src/renderer/src/features/library-slice.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index 9e671e34..9c399dbe 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -5,12 +5,10 @@ import type { LibraryGame } from "@types"; export interface LibraryState { value: LibraryGame[]; - searchQuery: string; } const initialState: LibraryState = { value: [], - searchQuery: "", }; export const librarySlice = createSlice({ @@ -38,9 +36,6 @@ export const librarySlice = createSlice({ game.newDownloadOptionsCount = undefined; } }, - setLibrarySearchQuery: (state, action: PayloadAction) => { - state.searchQuery = action.payload; - }, }, }); @@ -48,5 +43,4 @@ export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions, - setLibrarySearchQuery, } = librarySlice.actions; From 1521d7c058e1ff1091baa246a7032efe2ecab0bd Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 01:29:04 +0200 Subject: [PATCH 31/36] reverting changes --- src/main/main.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 1c203889..c6d54bc7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -17,7 +17,6 @@ import { Lock, DeckyPlugin, DownloadSourcesChecker, - WSClient, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -61,8 +60,6 @@ export const loadState = async () => { // Check for new download options on startup DownloadSourcesChecker.checkForChanges(); - - WSClient.connect(); }); const downloads = await downloadsSublevel From 25103e5eb7352542c32518b80c415f52b7112697 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 11 Nov 2025 09:09:03 +0000 Subject: [PATCH 32/36] ci: updating ci --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92fcebc3..5086d8e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,9 @@ name: Build on: pull_request: + push: + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} From a1eef4eab6110d06dead73153d58760ed82a82da Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 16:30:23 +0200 Subject: [PATCH 33/36] feat: check updates for installed games --- src/main/services/download-sources-checker.ts | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index b3675829..c0997a0f 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -5,6 +5,7 @@ import { updateDownloadSourcesCheckBaseline, updateDownloadSourcesSinceValue, downloadSourcesSublevel, + levelKeys, } from "@main/level"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; @@ -47,34 +48,49 @@ export class DownloadSourcesChecker { } private static async processApiResponse( - response: unknown, - nonCustomGames: Game[] + response: unknown ): Promise<{ gameId: string; count: number }[]> { if (!response || !Array.isArray(response)) { return []; } const gamesWithNewOptions: { gameId: string; count: number }[] = []; + const responseArray = response as DownloadSourcesChangeResponse[]; + const gamesWithUpdates = responseArray.filter( + (update) => update.newDownloadOptionsCount > 0 + ); - for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { - if (gameUpdate.newDownloadOptionsCount > 0) { - const game = nonCustomGames.find( - (g) => - g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + logger.info( + `API returned ${gamesWithUpdates.length} games with new download options (out of ${responseArray.length} total updates)` + ); + + for (const gameUpdate of gamesWithUpdates) { + const gameKey = levelKeys.game(gameUpdate.shop, gameUpdate.objectId); + const game = await gamesSublevel.get(gameKey).catch(() => null); + + if (!game) { + logger.info( + `Skipping update for ${gameKey} - game not found in database` ); - - if (game) { - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, - }); - - gamesWithNewOptions.push({ - gameId: `${game.shop}:${game.objectId}`, - count: gameUpdate.newDownloadOptionsCount, - }); - } + continue; } + + if (game.shop === "custom") { + logger.info( + `Skipping update for ${gameKey} - custom games are excluded` + ); + continue; + } + + await gamesSublevel.put(gameKey, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, + }); + + gamesWithNewOptions.push({ + gameId: gameKey, + count: gameUpdate.newDownloadOptionsCount, + }); } return gamesWithNewOptions; @@ -173,10 +189,7 @@ export class DownloadSourcesChecker { `Updated baseline to: ${now} (will be 'since' on next app start)` ); - const gamesWithNewOptions = await this.processApiResponse( - response, - nonCustomGames - ); + const gamesWithNewOptions = await this.processApiResponse(response); this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); From d59b96f44651d22a13b240afc9d12ae956a9c932 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 16:31:53 +0200 Subject: [PATCH 34/36] fix: typescript error --- src/main/services/download-sources-checker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index c0997a0f..890abee8 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -9,10 +9,10 @@ import { } from "@main/level"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; -import type { Game } from "@types"; +import type { Game, GameShop } from "@types"; interface DownloadSourcesChangeResponse { - shop: string; + shop: GameShop; objectId: string; newDownloadOptionsCount: number; downloadSourceIds: string[]; From 8dc5be1bdf7ab95c43e7a17758d06f2145e9d932 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 11 Nov 2025 20:01:28 +0200 Subject: [PATCH 35/36] reverting changes --- src/main/services/download-sources-checker.ts | 71 ++++++++----------- .../game-details/modals/repacks-modal.tsx | 2 - 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index 890abee8..928e3d52 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -5,14 +5,13 @@ import { updateDownloadSourcesCheckBaseline, updateDownloadSourcesSinceValue, downloadSourcesSublevel, - levelKeys, } from "@main/level"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; -import type { Game, GameShop } from "@types"; +import type { Game } from "@types"; interface DownloadSourcesChangeResponse { - shop: GameShop; + shop: string; objectId: string; newDownloadOptionsCount: number; downloadSourceIds: string[]; @@ -48,49 +47,34 @@ export class DownloadSourcesChecker { } private static async processApiResponse( - response: unknown + response: unknown, + nonCustomGames: Game[] ): Promise<{ gameId: string; count: number }[]> { if (!response || !Array.isArray(response)) { return []; } const gamesWithNewOptions: { gameId: string; count: number }[] = []; - const responseArray = response as DownloadSourcesChangeResponse[]; - const gamesWithUpdates = responseArray.filter( - (update) => update.newDownloadOptionsCount > 0 - ); - logger.info( - `API returned ${gamesWithUpdates.length} games with new download options (out of ${responseArray.length} total updates)` - ); - - for (const gameUpdate of gamesWithUpdates) { - const gameKey = levelKeys.game(gameUpdate.shop, gameUpdate.objectId); - const game = await gamesSublevel.get(gameKey).catch(() => null); - - if (!game) { - logger.info( - `Skipping update for ${gameKey} - game not found in database` + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId ); - continue; + + if (game) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount, + }); + } } - - if (game.shop === "custom") { - logger.info( - `Skipping update for ${gameKey} - custom games are excluded` - ); - continue; - } - - await gamesSublevel.put(gameKey, { - ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, - }); - - gamesWithNewOptions.push({ - gameId: gameKey, - count: gameUpdate.newDownloadOptionsCount, - }); } return gamesWithNewOptions; @@ -117,18 +101,18 @@ export class DownloadSourcesChecker { logger.info("DownloadSourcesChecker.checkForChanges() called"); try { - // Get all installed games (excluding custom games and games without executable) + // Get all installed games (excluding custom games) const installedGames = await gamesSublevel.values().all(); const nonCustomGames = installedGames.filter( - (game: Game) => game.shop !== "custom" && game.executablePath + (game: Game) => game.shop !== "custom" ); logger.info( - `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games with executable path` + `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games` ); if (nonCustomGames.length === 0) { logger.info( - "No non-custom games with executable path found, skipping download sources check" + "No non-custom games found, skipping download sources check" ); return; } @@ -189,7 +173,10 @@ export class DownloadSourcesChecker { `Updated baseline to: ${now} (will be 'since' on next app start)` ); - const gamesWithNewOptions = await this.processApiResponse(response); + const gamesWithNewOptions = await this.processApiResponse( + response, + nonCustomGames + ); this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index a72e1323..91013da0 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -200,8 +200,6 @@ export function RepacksModal({ if (viewedRepackIds.has(repack.id)) return false; - if (!game?.executablePath) return false; - if (!lastCheckTimestamp || !repack.createdAt) { return false; } From aebf6d1cae5ab6916039e472fee25f5e99fb472e Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 11 Nov 2025 18:22:39 +0000 Subject: [PATCH 36/36] feat: adding translations for new label --- src/locales/es/translation.json | 1 + src/locales/pt-BR/translation.json | 1 + src/locales/ru/translation.json | 1 + .../src/components/sidebar/sidebar-game-item.tsx | 5 +---- src/renderer/src/components/sidebar/sidebar.scss | 14 ++++---------- .../pages/game-details/modals/repacks-modal.scss | 5 ++--- 6 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 12e7e7fe..5a65d3cf 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -193,6 +193,7 @@ "download_in_progress": "Descarga en progreso", "download_paused": "Descarga pausada", "last_downloaded_option": "Última opción de descarga", + "new_download_option": "Nuevo", "create_steam_shortcut": "Crear atajo de Steam", "create_shortcut_success": "Atajo creado con éxito", "you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index fc0f4332..73d5e8fb 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -183,6 +183,7 @@ "download_in_progress": "Download em andamento", "download_paused": "Download pausado", "last_downloaded_option": "Última opção baixada", + "new_download_option": "Novo", "create_steam_shortcut": "Criar atalho na Steam", "create_shortcut_success": "Atalho criado com sucesso", "you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index c9527af8..b831ff2e 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -195,6 +195,7 @@ "download_in_progress": "Идёт загрузка", "download_paused": "Загрузка приостановлена", "last_downloaded_option": "Последний вариант загрузки", + "new_download_option": "Новый", "create_steam_shortcut": "Создать ярлык Steam", "create_shortcut_success": "Ярлык создан", "you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения", diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index ee16d418..23223fc5 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -83,10 +83,7 @@ export function SidebarGameItem({ {(game.newDownloadOptionsCount ?? 0) > 0 && ( -
    +
    -
    - {game.newDownloadOptionsCount} -
    + +{game.newDownloadOptionsCount}
    )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index fdb3872e..312cba3c 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -116,24 +116,18 @@ } &__game-badge { - background: rgba(255, 255, 255, 0.1); - color: #fff; + background-color: rgba(34, 197, 94, 0.15); + color: rgb(187, 247, 208); font-size: 10px; - font-weight: bold; + font-weight: 600; padding: 4px 6px; border-radius: 6px; display: flex; margin-left: auto; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - gap: calc(globals.$spacing-unit * 0.35); + border: 1px solid rgba(34, 197, 94, 0.5); } - &__game-badge-plus, - &__game-badge-count { - display: flex; - align-items: center; - justify-content: center; - } &__section-header { display: flex; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index 3f78c82c..420029c7 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -57,10 +57,9 @@ &__new-badge { background-color: rgba(34, 197, 94, 0.15); color: rgb(187, 247, 208); - padding: 2px 6px; + padding: 2px 8px; border-radius: 6px; - font-size: 10px; - font-weight: 600; + font-size: 9px; text-align: center; flex-shrink: 0; border: 1px solid rgba(34, 197, 94, 0.5);