feat: sidebar badge on new game download option

This commit is contained in:
Moyasee
2025-10-30 23:21:31 +02:00
parent 1bd88e6c6e
commit 101bc35460
16 changed files with 269 additions and 1 deletions

View File

@@ -0,0 +1,16 @@
import { levelKeys } from "./keys";
import { db } from "../level";
export const getLastDownloadSourcesCheck = async (): Promise<string | null> => {
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<void> => {
await db.put(levelKeys.lastDownloadSourcesCheck, timestamp);
};

View File

@@ -7,3 +7,4 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";

View File

@@ -18,4 +18,5 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
lastDownloadSourcesCheck: "lastDownloadSourcesCheck",
};

View File

@@ -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();
});

View File

@@ -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<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;
}
// 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);
}
}
}

View File

@@ -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<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;
}
}
}

View File

@@ -19,3 +19,4 @@ export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
export * from "./download-sources-checker";

View File

@@ -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),
});

View File

@@ -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<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary();
// Listen for new download options updates
useDownloadOptionsListener();
const { t } = useTranslation("app");
const { clearDownload, setLastPacket } = useDownload();

View File

@@ -80,6 +80,13 @@ export function SidebarGameItem({
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
{game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && (
<span className="sidebar__game-badge">
<div className="sidebar__game-badge-plus">+</div>
<div className="sidebar__game-badge-count">{game.newDownloadOptionsCount}</div>
</span>
)}
</button>
</li>

View File

@@ -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;

View File

@@ -414,6 +414,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 {

View File

@@ -18,7 +18,25 @@ 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;
}
},
},
});
export const { setLibrary } = librarySlice.actions;
export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions } = librarySlice.actions;

View File

@@ -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";

View 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) => {
gamesWithNewOptions.forEach(({ gameId, count }) => {
dispatch(updateGameNewDownloadOptions({ gameId, count }));
});
}
);
return unsubscribe;
}, [dispatch]);
}

View File

@@ -59,6 +59,7 @@ export interface Game {
pinnedDate?: Date | null;
automaticCloudSync?: boolean;
hasManuallyUpdatedPlaytime?: boolean;
newDownloadOptionsCount?: number;
}
export interface Download {