feat: custom game support/game info changing

This commit is contained in:
Moyasee
2025-09-19 16:58:58 +03:00
parent 3409b53268
commit f4e84e46cc
15 changed files with 556 additions and 32 deletions

View File

@@ -10,7 +10,15 @@ const saveGameShopAssets = async (
): Promise<void> => {
const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key);
return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets });
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle = existingAssets?.title && existingAssets.title !== assets.title;
return gamesShopAssetsSublevel.put(key, {
...existingAssets,
...assets,
title: shouldPreserveTitle ? existingAssets.title : assets.title
});
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -16,6 +16,7 @@ import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
import "./library/add-custom-game-to-library";
import "./library/update-custom-game";
import "./library/update-game-custom-assets";
import "./library/add-game-to-favorites";
import "./library/remove-game-from-favorites";
import "./library/create-game-shortcut";

View File

@@ -43,12 +43,14 @@ const addGameToLibrary = async (
await gamesSublevel.put(gameKey, game);
}
await createGame(game).catch(() => {});
if (game) {
await createGame(game).catch(() => {});
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
}
};
registerEvent("addGameToLibrary", addGameToLibrary);

View File

@@ -18,18 +18,14 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
// 确保返回的对象符合 LibraryGame 类型
return {
id: key,
...game,
download: download ?? null,
// 确保 gameAssets 中的可能为 null 的字段转换为 undefined
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? undefined,
libraryImageUrl: gameAssets?.libraryImageUrl ?? undefined,
logoImageUrl: gameAssets?.logoImageUrl ?? undefined,
logoPosition: gameAssets?.logoPosition ?? undefined,
coverImageUrl: gameAssets?.coverImageUrl ?? undefined,
};
...gameAssets,
// Ensure compatibility with LibraryGame type
libraryHeroImageUrl: game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
} as LibraryGame;
})
);
});

View File

@@ -0,0 +1,45 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const updateGameCustomAssets = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
title: string,
customIconUrl?: string | null,
customLogoImageUrl?: string | null,
customHeroImageUrl?: string | null
) => {
const gameKey = levelKeys.game(shop, objectId);
const existingGame = await gamesSublevel.get(gameKey);
if (!existingGame) {
throw new Error("Game not found");
}
const updatedGame = {
...existingGame,
title,
customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl,
customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl,
customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl,
};
await gamesSublevel.put(gameKey, updatedGame);
// Also update the shop assets for non-custom games
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
if (existingAssets) {
const updatedAssets = {
...existingAssets,
title, // Update the title in shop assets as well
};
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
}
return updatedGame;
};
registerEvent("updateGameCustomAssets", updateGameCustomAssets);

View File

@@ -57,7 +57,7 @@ export const mergeWithRemoteGames = async () => {
await gamesShopAssetsSublevel.put(gameKey, {
shop: game.shop,
objectId: game.objectId,
title: game.title,
title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl: game.coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,

View File

@@ -160,6 +160,23 @@ contextBridge.exposeInMainWorld("electron", {
logoImageUrl,
libraryHeroImageUrl
),
updateGameCustomAssets: (
shop: GameShop,
objectId: string,
title: string,
customIconUrl?: string | null,
customLogoImageUrl?: string | null,
customHeroImageUrl?: string | null
) =>
ipcRenderer.invoke(
"updateGameCustomAssets",
shop,
objectId,
title,
customIconUrl,
customLogoImageUrl,
customHeroImageUrl
),
createGameShortcut: (
shop: GameShop,
objectId: string,

View File

@@ -20,7 +20,7 @@ export function SidebarGameItem({
const isCustomGame = game.shop === "custom";
const sidebarIcon = isCustomGame
? game.libraryImageUrl || game.iconUrl
: game.iconUrl;
: game.customIconUrl || game.iconUrl;
return (
<li

View File

@@ -202,10 +202,10 @@ export function GameDetailsContextProvider({
}, [objectId, gameTitle, dispatch]);
useEffect(() => {
if (game?.title && game.shop === "custom") {
if (game?.title) {
dispatch(setHeaderTitle(game.title));
}
}, [game?.title, game?.shop, dispatch]);
}, [game?.title, dispatch]);
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {

View File

@@ -126,6 +126,14 @@ declare global {
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => Promise<Game>;
updateGameCustomAssets: (
shop: GameShop,
objectId: string,
title: string,
customIconUrl?: string | null,
customLogoImageUrl?: string | null,
customHeroImageUrl?: string | null
) => Promise<Game>;
createGameShortcut: (
shop: GameShop,
objectId: string,

View File

@@ -7,7 +7,7 @@ import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { EditCustomGameModal } from "./modals";
import { EditCustomGameModal, EditGameModal } from "./modals";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
@@ -66,6 +66,7 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false);
const [showEditGameModal, setShowEditGameModal] = useState(false);
const handleHeroLoad = async () => {
const output = await average(
@@ -105,6 +106,10 @@ export function GameDetailsContent() {
setShowEditCustomGameModal(true);
};
const handleEditGameClick = () => {
setShowEditGameModal(true);
};
const handleGameUpdated = (_updatedGame: any) => {
updateGame();
updateLibrary();
@@ -115,12 +120,29 @@ export function GameDetailsContent() {
}, [getGameArtifacts]);
const isCustomGame = game?.shop === "custom";
// Helper function to get image with custom asset priority
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
return customUrl || originalUrl || fallbackUrl || "";
};
const heroImage = isCustomGame
? game?.libraryHeroImageUrl || game?.iconUrl || ""
: shopDetails?.assets?.libraryHeroImageUrl || "";
: getImageWithCustomPriority(
game?.customHeroImageUrl,
shopDetails?.assets?.libraryHeroImageUrl
);
const logoImage = isCustomGame
? game?.logoImageUrl || "" // Don't use icon as fallback for custom games
: shopDetails?.assets?.logoImageUrl || "";
? game?.logoImageUrl || ""
: getImageWithCustomPriority(
game?.customLogoImageUrl,
shopDetails?.assets?.logoImageUrl
);
return (
<div
@@ -156,16 +178,14 @@ export function GameDetailsContent() {
)}
<div className="game-details__hero-buttons game-details__hero-buttons--right">
{game?.shop === "custom" && (
<button
type="button"
className="game-details__edit-custom-game-button"
onClick={handleEditCustomGameClick}
title={t("edit_custom_game")}
>
<PencilIcon size={16} />
</button>
)}
<button
type="button"
className="game-details__edit-custom-game-button"
onClick={game?.shop === "custom" ? handleEditCustomGameClick : handleEditGameClick}
title={t("edit_custom_game")}
>
<PencilIcon size={16} />
</button>
{game?.shop !== "custom" && (
<button
@@ -215,6 +235,15 @@ export function GameDetailsContent() {
onGameUpdated={handleGameUpdated}
/>
)}
{game?.shop !== "custom" && (
<EditGameModal
visible={showEditGameModal}
onClose={() => setShowEditGameModal(false)}
game={game}
onGameUpdated={handleGameUpdated}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
.edit-game-modal__container {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 500px;
}
.edit-game-modal__form {
display: flex;
flex-direction: column;
gap: 16px;
}
.edit-game-modal__image-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-game-modal__image-preview {
display: flex;
justify-content: center;
align-items: center;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 8px;
background-color: var(--color-background-secondary);
}
.edit-game-modal__preview-image {
max-width: 100%;
max-height: 120px;
object-fit: contain;
border-radius: 4px;
}
.edit-game-modal__actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 8px;
}
.edit-game-modal__actions button {
min-width: 100px;
}

View File

@@ -0,0 +1,367 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ImageIcon } from "@primer/octicons-react";
import { Modal, TextField, Button } from "@renderer/components";
import { useToast } from "@renderer/hooks";
import type { LibraryGame } from "@types";
import "./edit-game-modal.scss";
export interface EditGameModalProps {
visible: boolean;
onClose: () => void;
game: LibraryGame | null;
onGameUpdated: (updatedGame: any) => void;
}
export function EditGameModal({
visible,
onClose,
game,
onGameUpdated,
}: EditGameModalProps) {
const { t } = useTranslation("sidebar");
const { showSuccessToast, showErrorToast } = useToast();
const [gameName, setGameName] = useState("");
const [iconPath, setIconPath] = useState("");
const [logoPath, setLogoPath] = useState("");
const [heroPath, setHeroPath] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (game && visible) {
setGameName(game.title || "");
// For custom games, use existing logic
if (game.shop === "custom") {
const currentIconPath = game.iconUrl?.startsWith("local:")
? game.iconUrl.replace("local:", "")
: "";
const currentLogoPath = game.logoImageUrl?.startsWith("local:")
? game.logoImageUrl.replace("local:", "")
: "";
const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:")
? game.libraryHeroImageUrl.replace("local:", "")
: "";
setIconPath(currentIconPath);
setLogoPath(currentLogoPath);
setHeroPath(currentHeroPath);
} else {
// For non-custom games, use custom asset paths if they exist
const currentIconPath = game.customIconUrl?.startsWith("local:")
? game.customIconUrl.replace("local:", "")
: "";
const currentLogoPath = game.customLogoImageUrl?.startsWith("local:")
? game.customLogoImageUrl.replace("local:", "")
: "";
const currentHeroPath = game.customHeroImageUrl?.startsWith("local:")
? game.customHeroImageUrl.replace("local:", "")
: "";
setIconPath(currentIconPath);
setLogoPath(currentLogoPath);
setHeroPath(currentHeroPath);
}
}
}, [game, visible]);
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setGameName(event.target.value);
};
const handleSelectIcon = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: t("edit_custom_game_modal_image_filter"),
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
setIconPath(filePaths[0]);
}
};
const handleSelectLogo = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: t("edit_custom_game_modal_image_filter"),
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
setLogoPath(filePaths[0]);
}
};
const handleSelectHero = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: t("edit_custom_game_modal_image_filter"),
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
setHeroPath(filePaths[0]);
}
};
const handleUpdateGame = async () => {
if (!game || !gameName.trim()) {
showErrorToast(t("edit_custom_game_modal_fill_required"));
return;
}
setIsUpdating(true);
try {
let updatedGame;
if (game.shop === "custom") {
// For custom games, use existing logic
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
const libraryHeroImageUrl = heroPath
? `local:${heroPath}`
: game.libraryHeroImageUrl;
updatedGame = await window.electron.updateCustomGame(
game.shop,
game.objectId,
gameName.trim(),
iconUrl || undefined,
logoImageUrl || undefined,
libraryHeroImageUrl || undefined
);
} else {
// For non-custom games, update custom assets
const customIconUrl = iconPath ? `local:${iconPath}` : null;
const customLogoImageUrl = logoPath ? `local:${logoPath}` : null;
const customHeroImageUrl = heroPath ? `local:${heroPath}` : null;
updatedGame = await window.electron.updateGameCustomAssets(
game.shop,
game.objectId,
gameName.trim(),
customIconUrl,
customLogoImageUrl,
customHeroImageUrl
);
}
showSuccessToast(t("edit_custom_game_modal_success"));
onGameUpdated(updatedGame);
onClose();
} catch (error) {
console.error("Failed to update game:", error);
showErrorToast(
error instanceof Error
? error.message
: t("edit_custom_game_modal_failed")
);
} finally {
setIsUpdating(false);
}
};
const handleClose = () => {
if (!isUpdating && game) {
setGameName(game.title || "");
if (game.shop === "custom") {
const currentIconPath = game.iconUrl?.startsWith("local:")
? game.iconUrl.replace("local:", "")
: "";
const currentLogoPath = game.logoImageUrl?.startsWith("local:")
? game.logoImageUrl.replace("local:", "")
: "";
const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:")
? game.libraryHeroImageUrl.replace("local:", "")
: "";
setIconPath(currentIconPath);
setLogoPath(currentLogoPath);
setHeroPath(currentHeroPath);
} else {
const currentIconPath = game.customIconUrl?.startsWith("local:")
? game.customIconUrl.replace("local:", "")
: "";
const currentLogoPath = game.customLogoImageUrl?.startsWith("local:")
? game.customLogoImageUrl.replace("local:", "")
: "";
const currentHeroPath = game.customHeroImageUrl?.startsWith("local:")
? game.customHeroImageUrl.replace("local:", "")
: "";
setIconPath(currentIconPath);
setLogoPath(currentLogoPath);
setHeroPath(currentHeroPath);
}
onClose();
}
};
const isFormValid = gameName.trim();
const getIconPreviewUrl = () => {
return iconPath ? `local:${iconPath}` : null;
};
const getLogoPreviewUrl = () => {
return logoPath ? `local:${logoPath}` : null;
};
const getHeroPreviewUrl = () => {
return heroPath ? `local:${heroPath}` : null;
};
return (
<Modal
visible={visible}
title={t("edit_custom_game_modal")}
description={t("edit_custom_game_modal_description")}
onClose={handleClose}
>
<div className="edit-game-modal__container">
<div className="edit-game-modal__form">
<TextField
label={t("edit_custom_game_modal_game_name")}
placeholder={t("edit_custom_game_modal_enter_name")}
value={gameName}
onChange={handleGameNameChange}
theme="dark"
disabled={isUpdating}
/>
<div className="edit-game-modal__image-section">
<TextField
label={t("edit_custom_game_modal_icon")}
placeholder={t("edit_custom_game_modal_select_icon")}
value={iconPath}
readOnly
theme="dark"
rightContent={
<Button
type="button"
theme="outline"
onClick={handleSelectIcon}
disabled={isUpdating}
>
<ImageIcon />
{t("edit_custom_game_modal_browse")}
</Button>
}
/>
{iconPath && (
<div className="edit-game-modal__image-preview">
<img
src={getIconPreviewUrl()!}
alt={t("edit_custom_game_modal_icon_preview")}
className="edit-game-modal__preview-image"
/>
</div>
)}
</div>
<div className="edit-game-modal__image-section">
<TextField
label={t("edit_custom_game_modal_logo")}
placeholder={t("edit_custom_game_modal_select_logo")}
value={logoPath}
readOnly
theme="dark"
rightContent={
<Button
type="button"
theme="outline"
onClick={handleSelectLogo}
disabled={isUpdating}
>
<ImageIcon />
{t("edit_custom_game_modal_browse")}
</Button>
}
/>
{logoPath && (
<div className="edit-game-modal__image-preview">
<img
src={getLogoPreviewUrl()!}
alt={t("edit_custom_game_modal_logo_preview")}
className="edit-game-modal__preview-image"
/>
</div>
)}
</div>
<div className="edit-game-modal__image-section">
<TextField
label={t("edit_custom_game_modal_hero")}
placeholder={t("edit_custom_game_modal_select_hero")}
value={heroPath}
readOnly
theme="dark"
rightContent={
<Button
type="button"
theme="outline"
onClick={handleSelectHero}
disabled={isUpdating}
>
<ImageIcon />
{t("edit_custom_game_modal_browse")}
</Button>
}
/>
{heroPath && (
<div className="edit-game-modal__image-preview">
<img
src={getHeroPreviewUrl()!}
alt={t("edit_custom_game_modal_hero_preview")}
className="edit-game-modal__preview-image"
/>
</div>
)}
</div>
</div>
<div className="edit-game-modal__actions">
<Button
type="button"
theme="outline"
onClick={handleClose}
disabled={isUpdating}
>
{t("edit_custom_game_modal_cancel")}
</Button>
<Button
type="button"
theme="primary"
onClick={handleUpdateGame}
disabled={!isFormValid || isUpdating}
>
{isUpdating
? t("edit_custom_game_modal_updating")
: t("edit_custom_game_modal_update")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -2,3 +2,4 @@ export * from "./repacks-modal";
export * from "./download-settings-modal";
export * from "./game-options-modal";
export * from "./edit-custom-game-modal";
export * from "./edit-game-modal";

View File

@@ -35,6 +35,9 @@ export interface Game {
iconUrl: string | null;
libraryHeroImageUrl: string | null;
logoImageUrl: string | null;
customIconUrl?: string | null;
customLogoImageUrl?: string | null;
customHeroImageUrl?: string | null;
playTimeInMilliseconds: number;
unsyncedDeltaPlayTimeInMilliseconds?: number;
lastTimePlayed: Date | null;