mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-28 05:11:02 +00:00
Compare commits
35 Commits
feat/custo
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c0d3174b | ||
|
|
cd3fa10bf7 | ||
|
|
a57cc83076 | ||
|
|
c75a6ad439 | ||
|
|
05d68fa23b | ||
|
|
527a65e9bc | ||
|
|
fe6bb5763d | ||
|
|
002dff098c | ||
|
|
436d1b74be | ||
|
|
b89de065fe | ||
|
|
7fcdab07cb | ||
|
|
aebf6d1cae | ||
|
|
a2148dd1ef | ||
|
|
8dc5be1bdf | ||
|
|
133168c6c7 | ||
|
|
d59b96f446 | ||
|
|
a1eef4eab6 | ||
|
|
25103e5eb7 | ||
|
|
9cf0ef4b62 | ||
|
|
1521d7c058 | ||
|
|
14eb0f8172 | ||
|
|
860030a510 | ||
|
|
f0e4d241f9 | ||
|
|
44b24ab63d | ||
|
|
7c1adb70ea | ||
|
|
9854ed2f53 | ||
|
|
20338fa20b | ||
|
|
b578af4612 | ||
|
|
6f6b7d49ac | ||
|
|
5c445f8a90 | ||
|
|
87d35da9fc | ||
|
|
5067cf163e | ||
|
|
efab242c74 | ||
|
|
4dd3c9de76 | ||
|
|
101bc35460 |
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -2,6 +2,9 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
"download_in_progress": "Download in progress",
|
"download_in_progress": "Download in progress",
|
||||||
"download_paused": "Download paused",
|
"download_paused": "Download paused",
|
||||||
"last_downloaded_option": "Last downloaded option",
|
"last_downloaded_option": "Last downloaded option",
|
||||||
|
"new_download_option": "New",
|
||||||
"create_steam_shortcut": "Create Steam shortcut",
|
"create_steam_shortcut": "Create Steam shortcut",
|
||||||
"create_shortcut_success": "Shortcut created successfully",
|
"create_shortcut_success": "Shortcut created successfully",
|
||||||
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
|
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
|
||||||
|
|||||||
@@ -193,6 +193,7 @@
|
|||||||
"download_in_progress": "Descarga en progreso",
|
"download_in_progress": "Descarga en progreso",
|
||||||
"download_paused": "Descarga pausada",
|
"download_paused": "Descarga pausada",
|
||||||
"last_downloaded_option": "Última opción de descarga",
|
"last_downloaded_option": "Última opción de descarga",
|
||||||
|
"new_download_option": "Nuevo",
|
||||||
"create_steam_shortcut": "Crear atajo de Steam",
|
"create_steam_shortcut": "Crear atajo de Steam",
|
||||||
"create_shortcut_success": "Atajo creado con éxito",
|
"create_shortcut_success": "Atajo creado con éxito",
|
||||||
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
|
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"download_in_progress": "Download em andamento",
|
"download_in_progress": "Download em andamento",
|
||||||
"download_paused": "Download pausado",
|
"download_paused": "Download pausado",
|
||||||
"last_downloaded_option": "Última opção baixada",
|
"last_downloaded_option": "Última opção baixada",
|
||||||
|
"new_download_option": "Novo",
|
||||||
"create_steam_shortcut": "Criar atalho na Steam",
|
"create_steam_shortcut": "Criar atalho na Steam",
|
||||||
"create_shortcut_success": "Atalho criado com sucesso",
|
"create_shortcut_success": "Atalho criado com sucesso",
|
||||||
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações",
|
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações",
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
"download_in_progress": "Идёт загрузка",
|
"download_in_progress": "Идёт загрузка",
|
||||||
"download_paused": "Загрузка приостановлена",
|
"download_paused": "Загрузка приостановлена",
|
||||||
"last_downloaded_option": "Последний вариант загрузки",
|
"last_downloaded_option": "Последний вариант загрузки",
|
||||||
|
"new_download_option": "Новый",
|
||||||
"create_steam_shortcut": "Создать ярлык Steam",
|
"create_steam_shortcut": "Создать ярлык Steam",
|
||||||
"create_shortcut_success": "Ярлык создан",
|
"create_shortcut_success": "Ярлык создан",
|
||||||
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
|
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -20,6 +20,7 @@ import "./library/get-game-by-object-id";
|
|||||||
import "./library/get-library";
|
import "./library/get-library";
|
||||||
import "./library/refresh-library-assets";
|
import "./library/refresh-library-assets";
|
||||||
import "./library/extract-game-download";
|
import "./library/extract-game-download";
|
||||||
|
import "./library/clear-new-download-options";
|
||||||
import "./library/open-game";
|
import "./library/open-game";
|
||||||
import "./library/open-game-executable-path";
|
import "./library/open-game-executable-path";
|
||||||
import "./library/open-game-installer";
|
import "./library/open-game-installer";
|
||||||
@@ -65,6 +66,8 @@ import "./user-preferences/authenticate-real-debrid";
|
|||||||
import "./user-preferences/authenticate-torbox";
|
import "./user-preferences/authenticate-torbox";
|
||||||
import "./download-sources/add-download-source";
|
import "./download-sources/add-download-source";
|
||||||
import "./download-sources/sync-download-sources";
|
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/sign-out";
|
||||||
import "./auth/open-auth-window";
|
import "./auth/open-auth-window";
|
||||||
import "./auth/get-session-hash";
|
import "./auth/get-session-hash";
|
||||||
|
|||||||
27
src/main/events/library/clear-new-download-options.ts
Normal file
27
src/main/events/library/clear-new-download-options.ts
Normal file
@@ -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);
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
downloadsSublevel,
|
downloadsSublevel,
|
||||||
gamesShopAssetsSublevel,
|
gamesShopAssetsSublevel,
|
||||||
gamesSublevel,
|
gamesSublevel,
|
||||||
gameAchievementsSublevel,
|
|
||||||
} from "@main/level";
|
} from "@main/level";
|
||||||
|
|
||||||
const getLibrary = async (): Promise<LibraryGame[]> => {
|
const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||||
@@ -19,33 +18,19 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
|||||||
const download = await downloadsSublevel.get(key);
|
const download = await downloadsSublevel.get(key);
|
||||||
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
||||||
|
|
||||||
let unlockedAchievementCount = 0;
|
|
||||||
let achievementCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const achievements = await gameAchievementsSublevel.get(key);
|
|
||||||
if (achievements) {
|
|
||||||
achievementCount = achievements.achievements.length;
|
|
||||||
unlockedAchievementCount =
|
|
||||||
achievements.unlockedAchievements.length;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No achievements data for this game
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key,
|
id: key,
|
||||||
...game,
|
...game,
|
||||||
download: download ?? null,
|
download: download ?? null,
|
||||||
unlockedAchievementCount,
|
unlockedAchievementCount: game.unlockedAchievementCount ?? 0,
|
||||||
achievementCount,
|
achievementCount: game.achievementCount ?? 0,
|
||||||
// Spread gameAssets last to ensure all image URLs are properly set
|
// Spread gameAssets last to ensure all image URLs are properly set
|
||||||
...gameAssets,
|
...gameAssets,
|
||||||
// Preserve custom image URLs from game if they exist
|
// Preserve custom image URLs from game if they exist
|
||||||
customIconUrl: game.customIconUrl,
|
customIconUrl: game.customIconUrl,
|
||||||
customLogoImageUrl: game.customLogoImageUrl,
|
customLogoImageUrl: game.customLogoImageUrl,
|
||||||
customHeroImageUrl: game.customHeroImageUrl,
|
customHeroImageUrl: game.customHeroImageUrl,
|
||||||
} as LibraryGame;
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
59
src/main/level/sublevels/downloadSourcesCheckTimestamp.ts
Normal file
59
src/main/level/sublevels/downloadSourcesCheckTimestamp.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { levelKeys } from "./keys";
|
||||||
|
import { db } from "../level";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
// 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.downloadSourcesCheckBaseline);
|
||||||
|
return timestamp;
|
||||||
|
} catch (error) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Updates to current time (when app starts)
|
||||||
|
export const updateDownloadSourcesCheckBaseline = async (
|
||||||
|
timestamp: string
|
||||||
|
): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
const utcTimestamp = new Date(timestamp).toISOString();
|
||||||
|
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp);
|
||||||
|
};
|
||||||
@@ -7,3 +7,4 @@ export * from "./game-achievements";
|
|||||||
export * from "./keys";
|
export * from "./keys";
|
||||||
export * from "./themes";
|
export * from "./themes";
|
||||||
export * from "./download-sources";
|
export * from "./download-sources";
|
||||||
|
export * from "./downloadSourcesCheckTimestamp";
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ export const levelKeys = {
|
|||||||
screenState: "screenState",
|
screenState: "screenState",
|
||||||
rpcPassword: "rpcPassword",
|
rpcPassword: "rpcPassword",
|
||||||
downloadSources: "downloadSources",
|
downloadSources: "downloadSources",
|
||||||
|
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||||
|
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Ludusavi,
|
Ludusavi,
|
||||||
Lock,
|
Lock,
|
||||||
DeckyPlugin,
|
DeckyPlugin,
|
||||||
|
DownloadSourcesChecker,
|
||||||
WSClient,
|
WSClient,
|
||||||
} from "@main/services";
|
} from "@main/services";
|
||||||
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
||||||
@@ -57,6 +58,9 @@ export const loadState = async () => {
|
|||||||
|
|
||||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||||
void syncDownloadSourcesFromApi();
|
void syncDownloadSourcesFromApi();
|
||||||
|
|
||||||
|
// Check for new download options on startup
|
||||||
|
DownloadSourcesChecker.checkForChanges();
|
||||||
WSClient.connect();
|
WSClient.connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
188
src/main/services/download-sources-checker.ts
Normal file
188
src/main/services/download-sources-checker.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { HydraApi } from "./hydra-api";
|
||||||
|
import {
|
||||||
|
gamesSublevel,
|
||||||
|
getDownloadSourcesCheckBaseline,
|
||||||
|
updateDownloadSourcesCheckBaseline,
|
||||||
|
updateDownloadSourcesSinceValue,
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBaseline = await getDownloadSourcesCheckBaseline();
|
||||||
|
const since =
|
||||||
|
previousBaseline ||
|
||||||
|
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
logger.info(`Using since: ${since} (from last app start)`);
|
||||||
|
|
||||||
|
const clearedPayload = await this.clearStaleBadges(nonCustomGames);
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await HydraApi.checkDownloadSourcesChanges(
|
||||||
|
downloadSourceIds,
|
||||||
|
games,
|
||||||
|
since
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("API call completed, response:", response);
|
||||||
|
|
||||||
|
await updateDownloadSourcesSinceValue(since);
|
||||||
|
logger.info(`Saved 'since' value: ${since} (for modal comparison)`);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await updateDownloadSourcesCheckBaseline(now);
|
||||||
|
logger.info(
|
||||||
|
`Updated baseline to: ${now} (will be 'since' on next app start)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const gamesWithNewOptions = await this.processApiResponse(
|
||||||
|
response,
|
||||||
|
nonCustomGames
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions);
|
||||||
|
|
||||||
|
logger.info("Download sources check completed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to check download sources changes:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,4 +400,45 @@ export class HydraApi {
|
|||||||
.then((response) => response.data)
|
.then((response) => response.data)
|
||||||
.catch(this.handleUnauthorizedError);
|
.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<
|
||||||
|
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);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export * from "./wine";
|
|||||||
export * from "./lock";
|
export * from "./lock";
|
||||||
export * from "./decky-plugin";
|
export * from "./decky-plugin";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
export * from "./download-sources-checker";
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type ProfileGame = {
|
|||||||
hasManuallyUpdatedPlaytime: boolean;
|
hasManuallyUpdatedPlaytime: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
achievementCount: number;
|
||||||
|
unlockedAchievementCount: number;
|
||||||
} & ShopAssets;
|
} & ShopAssets;
|
||||||
|
|
||||||
export const mergeWithRemoteGames = async () => {
|
export const mergeWithRemoteGames = async () => {
|
||||||
@@ -39,6 +41,8 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
playTimeInMilliseconds: updatedPlayTime,
|
playTimeInMilliseconds: updatedPlayTime,
|
||||||
favorite: game.isFavorite ?? localGame.favorite,
|
favorite: game.isFavorite ?? localGame.favorite,
|
||||||
isPinned: game.isPinned ?? localGame.isPinned,
|
isPinned: game.isPinned ?? localGame.isPinned,
|
||||||
|
achievementCount: game.achievementCount,
|
||||||
|
unlockedAchievementCount: game.unlockedAchievementCount,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await gamesSublevel.put(gameKey, {
|
await gamesSublevel.put(gameKey, {
|
||||||
@@ -55,6 +59,8 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
favorite: game.isFavorite ?? false,
|
favorite: game.isFavorite ?? false,
|
||||||
isPinned: game.isPinned ?? false,
|
isPinned: game.isPinned ?? false,
|
||||||
|
achievementCount: game.achievementCount,
|
||||||
|
unlockedAchievementCount: game.unlockedAchievementCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
|
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
|
||||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||||
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
|
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
|
||||||
|
getDownloadSourcesCheckBaseline: () =>
|
||||||
|
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
|
||||||
|
getDownloadSourcesSinceValue: () =>
|
||||||
|
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
toggleAutomaticCloudSync: (
|
toggleAutomaticCloudSync: (
|
||||||
@@ -179,6 +183,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
||||||
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
||||||
|
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
|
||||||
|
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
|
||||||
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
|
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
|
||||||
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
|
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
|
||||||
updateLaunchOptions: (
|
updateLaunchOptions: (
|
||||||
@@ -600,6 +606,17 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
return () =>
|
return () =>
|
||||||
ipcRenderer.removeListener("on-custom-theme-updated", listener);
|
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) =>
|
closeEditorWindow: (themeId?: string) =>
|
||||||
ipcRenderer.invoke("closeEditorWindow", themeId),
|
ipcRenderer.invoke("closeEditorWindow", themeId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
useUserDetails,
|
useUserDetails,
|
||||||
} from "@renderer/hooks";
|
} from "@renderer/hooks";
|
||||||
|
import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener";
|
||||||
|
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,9 @@ export function App() {
|
|||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const { updateLibrary, library } = useLibrary();
|
const { updateLibrary, library } = useLibrary();
|
||||||
|
|
||||||
|
// Listen for new download options updates
|
||||||
|
useDownloadOptionsListener();
|
||||||
|
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
|
||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||||||
>
|
>
|
||||||
<div className="game-card__backdrop">
|
<div className="game-card__backdrop">
|
||||||
<img
|
<img
|
||||||
src={game.libraryImageUrl}
|
src={game.libraryImageUrl ?? undefined}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="game-card__cover"
|
className="game-card__cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ export function Hero() {
|
|||||||
>
|
>
|
||||||
<div className="hero__backdrop">
|
<div className="hero__backdrop">
|
||||||
<img
|
<img
|
||||||
src={game.libraryHeroImageUrl}
|
src={game.libraryHeroImageUrl ?? undefined}
|
||||||
alt={game.description ?? ""}
|
alt={game.description ?? ""}
|
||||||
className="hero__media"
|
className="hero__media"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<img
|
<img
|
||||||
src={game.logoImageUrl}
|
src={game.logoImageUrl ?? undefined}
|
||||||
width="250px"
|
width="250px"
|
||||||
alt={game.description ?? ""}
|
alt={game.description ?? ""}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ export function SidebarGameItem({
|
|||||||
<span className="sidebar__menu-item-button-label">
|
<span className="sidebar__menu-item-button-label">
|
||||||
{getGameTitle(game)}
|
{getGameTitle(game)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||||
|
<span className="sidebar__game-badge">
|
||||||
|
+{game.newDownloadOptionsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,19 @@
|
|||||||
background-size: cover;
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__game-badge {
|
||||||
|
background-color: rgba(34, 197, 94, 0.15);
|
||||||
|
color: rgb(187, 247, 208);
|
||||||
|
font-size: 10px;
|
||||||
|
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);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
&__section-header {
|
&__section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
11
src/renderer/src/declaration.d.ts
vendored
11
src/renderer/src/declaration.d.ts
vendored
@@ -142,6 +142,10 @@ declare global {
|
|||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
objectId: string
|
objectId: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
clearNewDownloadOptions: (
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string
|
||||||
|
) => Promise<void>;
|
||||||
toggleGamePin: (
|
toggleGamePin: (
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
objectId: string,
|
objectId: string,
|
||||||
@@ -215,6 +219,8 @@ declare global {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||||
syncDownloadSources: () => Promise<void>;
|
syncDownloadSources: () => Promise<void>;
|
||||||
|
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
|
||||||
|
getDownloadSourcesSinceValue: () => Promise<string | null>;
|
||||||
|
|
||||||
/* Hardware */
|
/* Hardware */
|
||||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||||
@@ -427,6 +433,11 @@ declare global {
|
|||||||
openEditorWindow: (themeId: string) => Promise<void>;
|
openEditorWindow: (themeId: string) => Promise<void>;
|
||||||
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
||||||
closeEditorWindow: (themeId?: string) => Promise<void>;
|
closeEditorWindow: (themeId?: string) => Promise<void>;
|
||||||
|
|
||||||
|
/* Download Options */
|
||||||
|
onNewDownloadOptions: (
|
||||||
|
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -20,10 +20,34 @@ export const librarySlice = createSlice({
|
|||||||
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
|
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
|
||||||
state.value = action.payload;
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
setLibrarySearchQuery: (state, action: PayloadAction<string>) => {
|
setLibrarySearchQuery: (state, action: PayloadAction<string>) => {
|
||||||
state.searchQuery = action.payload;
|
state.searchQuery = action.payload;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setLibrary, setLibrarySearchQuery } = librarySlice.actions;
|
export const {
|
||||||
|
setLibrary,
|
||||||
|
updateGameNewDownloadOptions,
|
||||||
|
clearNewDownloadOptions,
|
||||||
|
setLibrarySearchQuery,
|
||||||
|
} = librarySlice.actions;
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export * from "./redux";
|
|||||||
export * from "./use-user-details";
|
export * from "./use-user-details";
|
||||||
export * from "./use-format";
|
export * from "./use-format";
|
||||||
export * from "./use-feature";
|
export * from "./use-feature";
|
||||||
|
export * from "./use-download-options-listener";
|
||||||
export * from "./use-game-card";
|
export * from "./use-game-card";
|
||||||
|
|||||||
19
src/renderer/src/hooks/use-download-options-listener.ts
Normal file
19
src/renderer/src/hooks/use-download-options-listener.ts
Normal file
@@ -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) => {
|
||||||
|
for (const { gameId, count } of gamesWithNewOptions) {
|
||||||
|
dispatch(updateGameNewDownloadOptions({ gameId, count }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [dispatch]);
|
||||||
|
}
|
||||||
@@ -7,9 +7,25 @@ export function useLibrary() {
|
|||||||
const library = useAppSelector((state) => state.library.value);
|
const library = useAppSelector((state) => state.library.value);
|
||||||
|
|
||||||
const updateLibrary = useCallback(async () => {
|
const updateLibrary = useCallback(async () => {
|
||||||
return window.electron
|
return window.electron.getLibrary().then(async (updatedLibrary) => {
|
||||||
.getLibrary()
|
const libraryWithAchievements = await Promise.all(
|
||||||
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
|
updatedLibrary.map(async (game) => {
|
||||||
|
const unlockedAchievements =
|
||||||
|
await window.electron.getUnlockedAchievements(
|
||||||
|
game.objectId,
|
||||||
|
game.shop
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
unlockedAchievementCount:
|
||||||
|
game.unlockedAchievementCount || unlockedAchievements.length,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(setLibrary(libraryWithAchievements));
|
||||||
|
});
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
return { library, updateLibrary };
|
return { library, updateLibrary };
|
||||||
|
|||||||
@@ -45,12 +45,26 @@
|
|||||||
&__repack-title {
|
&__repack-title {
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit * 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__repack-info {
|
&__repack-info {
|
||||||
font-size: globals.$small-font-size;
|
font-size: globals.$small-font-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__new-badge {
|
||||||
|
background-color: rgba(34, 197, 94, 0.15);
|
||||||
|
color: rgb(187, 247, 208);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
&__no-results {
|
&__no-results {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: calc(globals.$spacing-unit * 4) 0;
|
padding: calc(globals.$spacing-unit * 4) 0;
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
} from "@renderer/components";
|
} from "@renderer/components";
|
||||||
import type { DownloadSource } from "@types";
|
import type { DownloadSource, GameRepack } from "@types";
|
||||||
import type { GameRepack } from "@types";
|
|
||||||
|
|
||||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import { orderBy } from "lodash-es";
|
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";
|
import "./repacks-modal.scss";
|
||||||
|
|
||||||
export interface RepacksModalProps {
|
export interface RepacksModalProps {
|
||||||
@@ -53,6 +53,13 @@ export function RepacksModal({
|
|||||||
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
|
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
const [lastCheckTimestamp, setLastCheckTimestamp] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true);
|
||||||
|
const [viewedRepackIds, setViewedRepackIds] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
const { game, repacks } = useContext(gameDetailsContext);
|
const { game, repacks } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ export function RepacksModal({
|
|||||||
|
|
||||||
const { formatDate } = useDate();
|
const { formatDate } = useDate();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const getHashFromMagnet = (magnet: string) => {
|
const getHashFromMagnet = (magnet: string) => {
|
||||||
if (!magnet || typeof magnet !== "string") {
|
if (!magnet || typeof magnet !== "string") {
|
||||||
@@ -97,6 +105,34 @@ export function RepacksModal({
|
|||||||
fetchDownloadSources();
|
fetchDownloadSources();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLastCheckTimestamp = async () => {
|
||||||
|
setIsLoadingTimestamp(true);
|
||||||
|
|
||||||
|
const timestamp = await window.electron.getDownloadSourcesSinceValue();
|
||||||
|
|
||||||
|
setLastCheckTimestamp(timestamp);
|
||||||
|
setIsLoadingTimestamp(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
fetchLastCheckTimestamp();
|
||||||
|
}
|
||||||
|
}, [visible, repacks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
visible &&
|
||||||
|
game?.newDownloadOptionsCount &&
|
||||||
|
game.newDownloadOptionsCount > 0
|
||||||
|
) {
|
||||||
|
globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId);
|
||||||
|
|
||||||
|
const gameId = `${game.shop}:${game.objectId}`;
|
||||||
|
dispatch(clearNewDownloadOptions({ gameId }));
|
||||||
|
}
|
||||||
|
}, [visible, game, dispatch]);
|
||||||
|
|
||||||
const sortedRepacks = useMemo(() => {
|
const sortedRepacks = useMemo(() => {
|
||||||
return orderBy(
|
return orderBy(
|
||||||
repacks,
|
repacks,
|
||||||
@@ -139,6 +175,7 @@ export function RepacksModal({
|
|||||||
const handleRepackClick = (repack: GameRepack) => {
|
const handleRepackClick = (repack: GameRepack) => {
|
||||||
setRepack(repack);
|
setRepack(repack);
|
||||||
setShowSelectFolderModal(true);
|
setShowSelectFolderModal(true);
|
||||||
|
setViewedRepackIds((prev) => new Set(prev).add(repack.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
@@ -158,6 +195,20 @@ export function RepacksModal({
|
|||||||
return repack.uris.some((uri) => uri.includes(game.download!.uri));
|
return repack.uris.some((uri) => uri.includes(game.download!.uri));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNewRepack = (repack: GameRepack): boolean => {
|
||||||
|
if (isLoadingTimestamp) return false;
|
||||||
|
|
||||||
|
if (viewedRepackIds.has(repack.id)) return false;
|
||||||
|
|
||||||
|
if (!lastCheckTimestamp || !repack.createdAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCheckUtc = new Date(lastCheckTimestamp).toISOString();
|
||||||
|
|
||||||
|
return repack.createdAt > lastCheckUtc;
|
||||||
|
};
|
||||||
|
|
||||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,7 +324,14 @@ export function RepacksModal({
|
|||||||
onClick={() => handleRepackClick(repack)}
|
onClick={() => handleRepackClick(repack)}
|
||||||
className="repacks-modal__repack-button"
|
className="repacks-modal__repack-button"
|
||||||
>
|
>
|
||||||
<p className="repacks-modal__repack-title">{repack.title}</p>
|
<p className="repacks-modal__repack-title">
|
||||||
|
{repack.title}
|
||||||
|
{isNewRepack(repack) && (
|
||||||
|
<span className="repacks-modal__new-badge">
|
||||||
|
{t("new_download_option")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
{isLastDownloadedOption && (
|
{isLastDownloadedOption && (
|
||||||
<Badge>{t("last_downloaded_option")}</Badge>
|
<Badge>{t("last_downloaded_option")}</Badge>
|
||||||
|
|||||||
@@ -84,7 +84,6 @@
|
|||||||
gap: calc(globals.$spacing-unit);
|
gap: calc(globals.$spacing-unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
&__logo-container {
|
&__logo-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -207,5 +206,4 @@
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { LibraryGame } from "@types";
|
import { LibraryGame } from "@types";
|
||||||
import { useGameCard } from "@renderer/hooks";
|
import { useGameCard } from "@renderer/hooks";
|
||||||
import {
|
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
|
||||||
ClockIcon,
|
|
||||||
AlertFillIcon,
|
|
||||||
TrophyIcon,
|
|
||||||
} from "@primer/octicons-react";
|
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import "./library-game-card-large.scss";
|
import "./library-game-card-large.scss";
|
||||||
|
|
||||||
@@ -16,36 +12,45 @@ interface LibraryGameCardLargeProps {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizePathForCss = (url: string | null | undefined): string => {
|
||||||
|
if (!url) return "";
|
||||||
|
return url.replaceAll("\\", "/");
|
||||||
|
};
|
||||||
|
|
||||||
const getImageWithCustomPriority = (
|
const getImageWithCustomPriority = (
|
||||||
customUrl: string | null | undefined,
|
customUrl: string | null | undefined,
|
||||||
originalUrl: string | null | undefined,
|
originalUrl: string | null | undefined,
|
||||||
fallbackUrl?: string | null | undefined
|
fallbackUrl?: string | null | undefined
|
||||||
) => {
|
) => {
|
||||||
return customUrl || originalUrl || fallbackUrl || "";
|
const selectedUrl = customUrl || originalUrl || fallbackUrl || "";
|
||||||
|
return normalizePathForCss(selectedUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
||||||
game,
|
game,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: Readonly<LibraryGameCardLargeProps>) {
|
}: Readonly<LibraryGameCardLargeProps>) {
|
||||||
const {
|
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
|
||||||
formatPlayTime,
|
useGameCard(game, onContextMenu);
|
||||||
handleCardClick,
|
|
||||||
handleContextMenuClick,
|
|
||||||
} = useGameCard(game, onContextMenu);
|
|
||||||
|
|
||||||
const backgroundImage = useMemo(
|
const backgroundImage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getImageWithCustomPriority(
|
getImageWithCustomPriority(
|
||||||
|
game.customHeroImageUrl,
|
||||||
game.libraryHeroImageUrl,
|
game.libraryHeroImageUrl,
|
||||||
game.libraryImageUrl,
|
game.libraryImageUrl ?? game.iconUrl
|
||||||
game.iconUrl
|
|
||||||
),
|
),
|
||||||
[game.libraryHeroImageUrl, game.libraryImageUrl, game.iconUrl]
|
[
|
||||||
|
game.customHeroImageUrl,
|
||||||
|
game.libraryHeroImageUrl,
|
||||||
|
game.libraryImageUrl,
|
||||||
|
game.iconUrl,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundStyle = useMemo(
|
const backgroundStyle = useMemo(
|
||||||
() => ({ backgroundImage: `url(${backgroundImage})` }),
|
() =>
|
||||||
|
backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {},
|
||||||
[backgroundImage]
|
[backgroundImage]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,7 +61,7 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
|
|||||||
[game.unlockedAchievementCount, game.achievementCount]
|
[game.unlockedAchievementCount, game.achievementCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoImage = game.logoImageUrl;
|
const logoImage = game.customLogoImageUrl ?? game.logoImageUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -209,8 +209,6 @@
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
&__game-image {
|
&__game-image {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { LibraryGame } from "@types";
|
import { LibraryGame } from "@types";
|
||||||
import { useGameCard } from "@renderer/hooks";
|
import { useGameCard } from "@renderer/hooks";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import {
|
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
|
||||||
ClockIcon,
|
|
||||||
AlertFillIcon,
|
|
||||||
TrophyIcon,
|
|
||||||
} from "@primer/octicons-react";
|
|
||||||
import "./library-game-card.scss";
|
import "./library-game-card.scss";
|
||||||
|
|
||||||
interface LibraryGameCardProps {
|
interface LibraryGameCardProps {
|
||||||
@@ -26,18 +22,17 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
|||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: Readonly<LibraryGameCardProps>) {
|
}: Readonly<LibraryGameCardProps>) {
|
||||||
const {
|
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
|
||||||
formatPlayTime,
|
useGameCard(game, onContextMenu);
|
||||||
handleCardClick,
|
|
||||||
handleContextMenuClick,
|
|
||||||
} = useGameCard(game, onContextMenu);
|
|
||||||
|
|
||||||
const coverImage =
|
const coverImage = (
|
||||||
|
game.customIconUrl ??
|
||||||
game.coverImageUrl ??
|
game.coverImageUrl ??
|
||||||
game.libraryImageUrl ??
|
game.libraryImageUrl ??
|
||||||
game.libraryHeroImageUrl ??
|
game.libraryHeroImageUrl ??
|
||||||
game.iconUrl ??
|
game.iconUrl ??
|
||||||
undefined;
|
""
|
||||||
|
).replaceAll("\\", "/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export default function Library() {
|
|||||||
onLibraryBatchComplete?: (cb: () => void) => () => void;
|
onLibraryBatchComplete?: (cb: () => void) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("compact");
|
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||||
|
const savedViewMode = localStorage.getItem("library-view-mode");
|
||||||
|
return (savedViewMode as ViewMode) || "compact";
|
||||||
|
});
|
||||||
const [filterBy, setFilterBy] = useState<FilterOption>("all");
|
const [filterBy, setFilterBy] = useState<FilterOption>("all");
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
game: LibraryGame | null;
|
game: LibraryGame | null;
|
||||||
@@ -31,6 +34,11 @@ export default function Library() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation("library");
|
const { t } = useTranslation("library");
|
||||||
|
|
||||||
|
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||||
|
setViewMode(mode);
|
||||||
|
localStorage.setItem("library-view-mode", mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(setHeaderTitle(t("library")));
|
dispatch(setHeaderTitle(t("library")));
|
||||||
const electron = (globalThis as unknown as { electron?: ElectronAPI })
|
const electron = (globalThis as unknown as { electron?: ElectronAPI })
|
||||||
@@ -71,7 +79,7 @@ export default function Library() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseContextMenu = useCallback(() => {
|
const handleCloseContextMenu = useCallback(() => {
|
||||||
setContextMenu({ game: null, visible: false, position: { x: 0, y: 0 } });
|
setContextMenu((prev) => ({ ...prev, visible: false }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredLibrary = useMemo(() => {
|
const filteredLibrary = useMemo(() => {
|
||||||
@@ -147,7 +155,10 @@ export default function Library() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="library__controls-right">
|
<div className="library__controls-right">
|
||||||
<ViewOptions viewMode={viewMode} onViewModeChange={setViewMode} />
|
<ViewOptions
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={handleViewModeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface GameRepack {
|
|||||||
uploadDate: string | null;
|
uploadDate: string | null;
|
||||||
downloadSourceId: string;
|
downloadSourceId: string;
|
||||||
downloadSourceName: string;
|
downloadSourceName: string;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadSource {
|
export interface DownloadSource {
|
||||||
@@ -41,9 +42,9 @@ export interface ShopAssets {
|
|||||||
shop: GameShop;
|
shop: GameShop;
|
||||||
title: string;
|
title: string;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
libraryHeroImageUrl: string;
|
libraryHeroImageUrl: string | null;
|
||||||
libraryImageUrl: string;
|
libraryImageUrl: string | null;
|
||||||
logoImageUrl: string;
|
logoImageUrl: string | null;
|
||||||
logoPosition: string | null;
|
logoPosition: string | null;
|
||||||
coverImageUrl: string | null;
|
coverImageUrl: string | null;
|
||||||
downloadSources: string[];
|
downloadSources: string[];
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ export interface Game {
|
|||||||
launchOptions?: string | null;
|
launchOptions?: string | null;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
achievementCount?: number;
|
||||||
|
unlockedAchievementCount?: number;
|
||||||
pinnedDate?: Date | null;
|
pinnedDate?: Date | null;
|
||||||
automaticCloudSync?: boolean;
|
automaticCloudSync?: boolean;
|
||||||
hasManuallyUpdatedPlaytime?: boolean;
|
hasManuallyUpdatedPlaytime?: boolean;
|
||||||
|
newDownloadOptionsCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Download {
|
export interface Download {
|
||||||
|
|||||||
Reference in New Issue
Block a user