mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Merge pull request #1842 from hydralauncher/feat/displaying-new-game-update
Feat: displaying new game update
This commit is contained in:
@@ -197,6 +197,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, чтобы увидеть изменения",
|
||||
|
||||
@@ -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/refresh-library-assets";
|
||||
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";
|
||||
@@ -65,6 +66,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";
|
||||
|
||||
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);
|
||||
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 "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
|
||||
@@ -18,4 +18,6 @@ export const levelKeys = {
|
||||
screenState: "screenState",
|
||||
rpcPassword: "rpcPassword",
|
||||
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,
|
||||
Lock,
|
||||
DeckyPlugin,
|
||||
DownloadSourcesChecker,
|
||||
WSClient,
|
||||
} from "@main/services";
|
||||
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
||||
@@ -57,6 +58,9 @@ export const loadState = async () => {
|
||||
|
||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||
void syncDownloadSourcesFromApi();
|
||||
|
||||
// Check for new download options on startup
|
||||
DownloadSourcesChecker.checkForChanges();
|
||||
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)
|
||||
.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 "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
|
||||
@@ -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: (
|
||||
@@ -179,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: (
|
||||
@@ -600,6 +606,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),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,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 {
|
||||
@@ -40,6 +41,9 @@ export function App() {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { updateLibrary, library } = useLibrary();
|
||||
|
||||
// Listen for new download options updates
|
||||
useDownloadOptionsListener();
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
@@ -80,6 +80,12 @@ export function SidebarGameItem({
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
|
||||
{(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||
<span className="sidebar__game-badge">
|
||||
+{game.newDownloadOptionsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -115,6 +115,20 @@
|
||||
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 {
|
||||
display: flex;
|
||||
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,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
clearNewDownloadOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
toggleGamePin: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -215,6 +219,8 @@ declare global {
|
||||
) => Promise<void>;
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
syncDownloadSources: () => Promise<void>;
|
||||
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
|
||||
getDownloadSourcesSinceValue: () => Promise<string | null>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
@@ -427,6 +433,11 @@ declare global {
|
||||
openEditorWindow: (themeId: string) => Promise<void>;
|
||||
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
closeEditorWindow: (themeId?: string) => Promise<void>;
|
||||
|
||||
/* Download Options */
|
||||
onNewDownloadOptions: (
|
||||
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -20,10 +20,34 @@ export const librarySlice = createSlice({
|
||||
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
|
||||
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>) => {
|
||||
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-format";
|
||||
export * from "./use-feature";
|
||||
export * from "./use-download-options-listener";
|
||||
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]);
|
||||
}
|
||||
@@ -45,12 +45,26 @@
|
||||
&__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(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 {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 4) 0;
|
||||
|
||||
@@ -15,14 +15,14 @@ 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";
|
||||
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 {
|
||||
@@ -53,6 +53,13 @@ export function RepacksModal({
|
||||
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);
|
||||
|
||||
@@ -60,6 +67,7 @@ export function RepacksModal({
|
||||
|
||||
const { formatDate } = useDate();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getHashFromMagnet = (magnet: string) => {
|
||||
if (!magnet || typeof magnet !== "string") {
|
||||
@@ -97,6 +105,34 @@ 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]);
|
||||
|
||||
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(() => {
|
||||
return orderBy(
|
||||
repacks,
|
||||
@@ -139,6 +175,7 @@ export function RepacksModal({
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
setShowSelectFolderModal(true);
|
||||
setViewedRepackIds((prev) => new Set(prev).add(repack.id));
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
@@ -158,6 +195,20 @@ export function RepacksModal({
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -273,7 +324,14 @@ export function RepacksModal({
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
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 && (
|
||||
<Badge>{t("last_downloaded_option")}</Badge>
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface GameRepack {
|
||||
uploadDate: string | null;
|
||||
downloadSourceId: string;
|
||||
downloadSourceName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DownloadSource {
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface Game {
|
||||
pinnedDate?: Date | null;
|
||||
automaticCloudSync?: boolean;
|
||||
hasManuallyUpdatedPlaytime?: boolean;
|
||||
newDownloadOptionsCount?: number;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
|
||||
Reference in New Issue
Block a user