Feat: Custom Games

This commit is contained in:
Moyasee
2025-09-19 16:18:49 +03:00
parent 2604dfea22
commit 7e59e02d03
28 changed files with 1145 additions and 51 deletions

View File

@@ -28,7 +28,60 @@
"friends": "Friends",
"need_help": "Need help?",
"favorites": "Favorites",
"playable_button_title": "Show only games you can play now"
"playable_button_title": "Show only games you can play now",
"add_custom_game_tooltip": "Add Custom Game",
"show_playable_only_tooltip": "Show Playable Only",
"custom_game_modal": "Add Custom Game",
"custom_game_modal_description": "Add a custom game to your library by selecting an executable file",
"custom_game_modal_executable_path": "Executable Path",
"custom_game_modal_select_executable": "Select executable file",
"custom_game_modal_game_name": "Game Name",
"custom_game_modal_enter_name": "Enter game name",
"custom_game_modal_image": "Game Image",
"custom_game_modal_select_image": "Select game image",
"custom_game_modal_image_preview": "Game image preview",
"custom_game_modal_browse": "Browse",
"custom_game_modal_cancel": "Cancel",
"custom_game_modal_add": "Add Game",
"custom_game_modal_adding": "Adding Game...",
"custom_game_modal_fill_required": "Please fill in all required fields",
"custom_game_modal_success": "Custom game added successfully",
"custom_game_modal_failed": "Failed to add custom game",
"custom_game_modal_executable": "Executable",
"custom_game_modal_image_filter": "Image",
"custom_game_modal_icon": "Game Icon",
"custom_game_modal_select_icon": "Select game icon",
"custom_game_modal_icon_preview": "Game icon preview",
"custom_game_modal_logo": "Game Logo",
"custom_game_modal_select_logo": "Select game logo",
"custom_game_modal_logo_preview": "Game logo preview",
"custom_game_modal_hero": "Library Hero Image",
"custom_game_modal_select_hero": "Select library hero image",
"custom_game_modal_hero_preview": "Library hero image preview",
"edit_custom_game_modal": "Edit Custom Game",
"edit_custom_game_modal_description": "Edit your custom game details",
"edit_custom_game_modal_game_name": "Game Name",
"edit_custom_game_modal_enter_name": "Enter game name",
"edit_custom_game_modal_image": "Game Image",
"edit_custom_game_modal_select_image": "Select game image",
"edit_custom_game_modal_browse": "Browse",
"edit_custom_game_modal_image_preview": "Game image preview",
"edit_custom_game_modal_icon": "Game Icon",
"edit_custom_game_modal_select_icon": "Select game icon",
"edit_custom_game_modal_icon_preview": "Game icon preview",
"edit_custom_game_modal_logo": "Game Logo",
"edit_custom_game_modal_select_logo": "Select game logo",
"edit_custom_game_modal_logo_preview": "Game logo preview",
"edit_custom_game_modal_hero": "Library Hero Image",
"edit_custom_game_modal_select_hero": "Select library hero image",
"edit_custom_game_modal_hero_preview": "Library hero image preview",
"edit_custom_game_modal_cancel": "Cancel",
"edit_custom_game_modal_update": "Update Game",
"edit_custom_game_modal_updating": "Updating Game...",
"edit_custom_game_modal_fill_required": "Please fill in all required fields",
"edit_custom_game_modal_success": "Custom game updated successfully",
"edit_custom_game_modal_failed": "Failed to update custom game",
"edit_custom_game_modal_image_filter": "Image"
},
"header": {
"search": "Search games",

View File

@@ -28,7 +28,58 @@
"friends": "Друзья",
"need_help": "Нужна помощь?",
"favorites": "Избранное",
"playable_button_title": "Показать только игры, в которые можно играть сейчас"
"playable_button_title": "Показать только игры, в которые можно играть сейчас",
"custom_game_modal": "Добавить пользовательскую игру",
"custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл",
"custom_game_modal_executable_path": "Путь к исполняемому файлу",
"custom_game_modal_select_executable": "Выберите исполняемый файл",
"custom_game_modal_game_name": "Название игры",
"custom_game_modal_enter_name": "Введите название игры",
"custom_game_modal_image": "Изображение игры",
"custom_game_modal_select_image": "Выберите изображение игры",
"custom_game_modal_image_preview": "Предварительный просмотр изображения игры",
"custom_game_modal_browse": "Обзор",
"custom_game_modal_cancel": "Отмена",
"custom_game_modal_add": "Добавить игру",
"custom_game_modal_adding": "Добавление игры...",
"custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля",
"custom_game_modal_success": "Пользовательская игра успешно добавлена",
"custom_game_modal_failed": "Не удалось добавить пользовательскую игру",
"custom_game_modal_executable": "Исполняемый файл",
"custom_game_modal_image_filter": "Изображение",
"custom_game_modal_icon": "Иконка игры",
"custom_game_modal_select_icon": "Выберите иконку игры",
"custom_game_modal_icon_preview": "Предпросмотр иконки игры",
"custom_game_modal_logo": "Логотип игры",
"custom_game_modal_select_logo": "Выберите логотип игры",
"custom_game_modal_logo_preview": "Предпросмотр логотипа игры",
"custom_game_modal_hero": "Изображение героя библиотеки",
"custom_game_modal_select_hero": "Выберите изображение героя библиотеки",
"custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки",
"edit_custom_game_modal": "Редактировать пользовательскую игру",
"edit_custom_game_modal_description": "Редактируйте детали вашей пользовательской игры",
"edit_custom_game_modal_game_name": "Название игры",
"edit_custom_game_modal_enter_name": "Введите название игры",
"edit_custom_game_modal_image": "Изображение игры",
"edit_custom_game_modal_select_image": "Выберите изображение игры",
"edit_custom_game_modal_browse": "Обзор",
"edit_custom_game_modal_image_preview": "Предпросмотр изображения игры",
"edit_custom_game_modal_icon": "Иконка игры",
"edit_custom_game_modal_select_icon": "Выберите иконку игры",
"edit_custom_game_modal_icon_preview": "Предпросмотр иконки игры",
"edit_custom_game_modal_logo": "Логотип игры",
"edit_custom_game_modal_select_logo": "Выберите логотип игры",
"edit_custom_game_modal_logo_preview": "Предпросмотр логотипа игры",
"edit_custom_game_modal_hero": "Изображение героя библиотеки",
"edit_custom_game_modal_select_hero": "Выберите изображение героя библиотеки",
"edit_custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки",
"edit_custom_game_modal_cancel": "Отмена",
"edit_custom_game_modal_update": "Обновить игру",
"edit_custom_game_modal_updating": "Обновление игры...",
"edit_custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля",
"edit_custom_game_modal_success": "Пользовательская игра успешно обновлена",
"edit_custom_game_modal_failed": "Не удалось обновить пользовательскую игру",
"edit_custom_game_modal_image_filter": "Изображение"
},
"header": {
"search": "Поиск",

View File

@@ -14,6 +14,8 @@ import "./catalogue/get-developers";
import "./hardware/get-disk-free-space";
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/add-game-to-favorites";
import "./library/remove-game-from-favorites";
import "./library/create-game-shortcut";

View File

@@ -0,0 +1,68 @@
import { registerEvent } from "../register-event";
import {
gamesSublevel,
gamesShopAssetsSublevel,
levelKeys,
} from "@main/level";
import { randomUUID } from "crypto";
import type { GameShop } from "@types";
const addCustomGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
title: string,
executablePath: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => {
const objectId = randomUUID();
const shop: GameShop = "custom";
const gameKey = levelKeys.game(shop, objectId);
const existingGames = await gamesSublevel.iterator().all();
const existingGame = existingGames.find(([_key, game]) =>
game.executablePath === executablePath && !game.isDeleted
);
if (existingGame) {
throw new Error("A game with this executable path already exists in your library");
}
const assets = {
objectId,
shop,
title,
iconUrl: iconUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || "",
libraryImageUrl: iconUrl || "",
logoImageUrl: logoImageUrl || "",
logoPosition: null,
coverImageUrl: iconUrl || "",
};
await gamesShopAssetsSublevel.put(gameKey, assets);
const game = {
title,
iconUrl: iconUrl || null,
logoImageUrl: logoImageUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || null,
objectId,
shop,
remoteId: null,
isDeleted: false,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
executablePath,
launchOptions: null,
favorite: false,
automaticCloudSync: false,
hasManuallyUpdatedPlaytime: false,
};
await gamesSublevel.put(gameKey, game);
return game;
};
registerEvent("addCustomGameToLibrary", addCustomGameToLibrary);

View File

@@ -13,16 +13,20 @@ const changeGamePlaytime = async (
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
playTimeInSeconds,
});
if (game.remoteId) {
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
playTimeInSeconds,
});
}
await gamesSublevel.put(gameKey, {
...game,
playTimeInMilliseconds: playTimeInSeconds * 1000,
hasManuallyUpdatedPlaytime: true,
});
} catch (error) {
throw new Error(`Failed to update game favorite status: ${error}`);
throw new Error(`Failed to update game playtime: ${error}`);
}
};

View File

@@ -0,0 +1,53 @@
import { registerEvent } from "../register-event";
import {
gamesSublevel,
gamesShopAssetsSublevel,
levelKeys,
} from "@main/level";
import type { GameShop } from "@types";
const updateCustomGame = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
title: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const existingGame = await gamesSublevel.get(gameKey);
if (!existingGame) {
throw new Error("Game not found");
}
const updatedGame = {
...existingGame,
title,
iconUrl: iconUrl || null,
logoImageUrl: logoImageUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || null,
};
await gamesSublevel.put(gameKey, updatedGame);
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
if (existingAssets) {
const updatedAssets = {
...existingAssets,
title,
iconUrl: iconUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || "",
libraryImageUrl: iconUrl || "",
logoImageUrl: logoImageUrl || "",
coverImageUrl: iconUrl || "",
};
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
}
return updatedGame;
};
registerEvent("updateCustomGame", updateCustomGame);

View File

@@ -64,6 +64,54 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
protocol.handle("gradient", (request) => {
const gradientCss = decodeURIComponent(request.url.slice("gradient:".length));
const match = gradientCss.match(/linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/);
let direction = "45deg";
let color1 = '#4a90e2';
let color2 = '#7b68ee';
if (match) {
direction = match[1].trim();
color1 = match[2].trim();
color2 = match[3].trim();
}
let x1 = "0%", y1 = "0%", x2 = "100%", y2 = "100%";
if (direction === "to right") {
x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "0%";
} else if (direction === "to bottom") {
x1 = "0%"; y1 = "0%"; x2 = "0%"; y2 = "100%";
} else if (direction === "45deg") {
x1 = "0%"; y1 = "100%"; x2 = "100%"; y2 = "0%";
} else if (direction === "135deg") {
x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "100%";
} else if (direction === "225deg") {
x1 = "100%"; y1 = "0%"; x2 = "0%"; y2 = "100%";
} else if (direction === "315deg") {
x1 = "100%"; y1 = "100%"; x2 = "0%"; y2 = "0%";
}
const svgContent = `
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#grad)" />
</svg>
`;
return new Response(svgContent, {
headers: { 'Content-Type': 'image/svg+xml' }
});
});
await loadState();
const language = await db

View File

@@ -44,6 +44,8 @@ export const mergeWithRemoteGames = async () => {
remoteId: game.id,
shop: game.shop,
iconUrl: game.iconUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
logoImageUrl: game.logoImageUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,

View File

@@ -11,7 +11,7 @@ export const uploadGamesBatch = async () => {
.all()
.then((results) => {
return results.filter(
(game) => !game.isDeleted && game.remoteId === null
(game) => !game.isDeleted && game.remoteId === null && game.shop !== "custom"
);
});

View File

@@ -128,6 +128,23 @@ contextBridge.exposeInMainWorld("electron", {
),
addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
addCustomGameToLibrary: (
title: string,
executablePath: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) =>
ipcRenderer.invoke("addCustomGameToLibrary", title, executablePath, iconUrl, logoImageUrl, libraryHeroImageUrl),
updateCustomGame: (
shop: GameShop,
objectId: string,
title: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) =>
ipcRenderer.invoke("updateCustomGame", shop, objectId, title, iconUrl, logoImageUrl, libraryHeroImageUrl),
createGameShortcut: (
shop: GameShop,
objectId: string,
@@ -476,4 +493,6 @@ contextBridge.exposeInMainWorld("electron", {
},
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
});

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@@ -0,0 +1,52 @@
@use "../../scss/globals.scss";
.sidebar-adding-custom-game-modal {
&__container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 500px;
margin: 0 auto;
text-align: center;
}
&__form {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
text-align: left;
}
&__image-section {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
text-align: left;
}
&__image-preview {
display: flex;
justify-content: center;
align-items: center;
padding: globals.$spacing-unit;
border: 1px solid globals.$border-color;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
}
&__preview-image {
max-width: 120px;
max-height: 80px;
width: auto;
height: auto;
border-radius: 4px;
object-fit: contain;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { FileDirectoryIcon } from "@primer/octicons-react";
import { Modal, TextField, Button } from "@renderer/components";
import { useLibrary, useToast } from "@renderer/hooks";
import { buildGameDetailsPath, generateRandomGradient } from "@renderer/helpers";
import "./sidebar-adding-custom-game-modal.scss";
export interface SidebarAddingCustomGameModalProps {
visible: boolean;
onClose: () => void;
}
export function SidebarAddingCustomGameModal({
visible,
onClose,
}: SidebarAddingCustomGameModalProps) {
const { t } = useTranslation("sidebar");
const { updateLibrary } = useLibrary();
const { showSuccessToast, showErrorToast } = useToast();
const navigate = useNavigate();
const [gameName, setGameName] = useState("");
const [executablePath, setExecutablePath] = useState("");
const [isAdding, setIsAdding] = useState(false);
const handleSelectExecutable = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: t("custom_game_modal_executable"),
extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"],
},
],
});
if (filePaths && filePaths.length > 0) {
const selectedPath = filePaths[0];
setExecutablePath(selectedPath);
if (!gameName.trim()) {
const fileName = selectedPath.split(/[\\/]/).pop() || "";
const gameNameFromFile = fileName.replace(/\.[^/.]+$/, "");
setGameName(gameNameFromFile);
}
}
};
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setGameName(event.target.value);
};
const handleAddGame = async () => {
if (!gameName.trim() || !executablePath.trim()) {
showErrorToast(t("custom_game_modal_fill_required"));
return;
}
setIsAdding(true);
try {
// Generate gradient URL only for hero image
const gameNameForSeed = gameName.trim();
const iconUrl = ""; // Don't use gradient for icon
const logoImageUrl = ""; // Don't use gradient for logo
const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero
const newGame = await window.electron.addCustomGameToLibrary(
gameNameForSeed,
executablePath,
iconUrl,
logoImageUrl,
libraryHeroImageUrl
);
showSuccessToast(t("custom_game_modal_success"));
updateLibrary();
const gameDetailsPath = buildGameDetailsPath({
shop: "custom",
objectId: newGame.objectId,
title: newGame.title
});
navigate(gameDetailsPath);
setGameName("");
setExecutablePath("");
onClose();
} catch (error) {
console.error("Failed to add custom game:", error);
showErrorToast(
error instanceof Error
? error.message
: t("custom_game_modal_failed")
);
} finally {
setIsAdding(false);
}
};
const handleClose = () => {
if (!isAdding) {
setGameName("");
setExecutablePath("");
onClose();
}
};
const isFormValid = gameName.trim() && executablePath.trim();
return (
<Modal
visible={visible}
title={t("custom_game_modal")}
description={t("custom_game_modal_description")}
onClose={handleClose}
>
<div className="sidebar-adding-custom-game-modal__container">
<div className="sidebar-adding-custom-game-modal__form">
<TextField
label={t("custom_game_modal_executable_path")}
placeholder={t("custom_game_modal_select_executable")}
value={executablePath}
readOnly
theme="dark"
rightContent={
<Button
type="button"
theme="outline"
onClick={handleSelectExecutable}
disabled={isAdding}
>
<FileDirectoryIcon />
{t("custom_game_modal_browse")}
</Button>
}
/>
<TextField
label={t("custom_game_modal_game_name")}
placeholder={t("custom_game_modal_enter_name")}
value={gameName}
onChange={handleGameNameChange}
theme="dark"
disabled={isAdding}
/>
</div>
<div className="sidebar-adding-custom-game-modal__actions">
<Button
type="button"
theme="outline"
onClick={handleClose}
disabled={isAdding}
>
{t("custom_game_modal_cancel")}
</Button>
<Button
type="button"
theme="primary"
onClick={handleAddGame}
disabled={!isFormValid || isAdding}
>
{isAdding ? t("custom_game_modal_adding") : t("custom_game_modal_add")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,4 +1,5 @@
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import PlayLogo from "@renderer/assets/play-logo.svg?react";
import { LibraryGame } from "@types";
import cn from "classnames";
import { useLocation } from "react-router-dom";
@@ -16,6 +17,11 @@ export function SidebarGameItem({
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
const isCustomGame = game.shop === "custom";
const sidebarIcon = isCustomGame
? game.libraryImageUrl || game.iconUrl
: game.iconUrl;
return (
<li
key={game.id}
@@ -30,13 +36,15 @@ export function SidebarGameItem({
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
{sidebarIcon ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
src={sidebarIcon}
alt={game.title}
loading="lazy"
/>
) : isCustomGame ? (
<PlayLogo className="sidebar__game-icon" />
) : (
<SteamLogo className="sidebar__game-icon" />
)}

View File

@@ -172,4 +172,24 @@
display: block;
}
}
&__add-button {
background: none;
border: none;
color: globals.$muted-color;
cursor: pointer;
padding: 0;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&:active {
color: rgba(255, 255, 255, 0.5);
}
svg {
display: block;
}
}
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Tooltip } from "react-tooltip";
import type { LibraryGame } from "@types";
@@ -21,8 +22,9 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react";
import { CommentDiscussionIcon, PlayIcon, PlusIcon } from "@primer/octicons-react";
import { SidebarGameItem } from "./sidebar-game-item";
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
import { useDispatch } from "react-redux";
@@ -63,11 +65,20 @@ export function Sidebar() {
const { showWarningToast } = useToast();
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
const [showAddGameModal, setShowAddGameModal] = useState(false);
const handlePlayButtonClick = () => {
setShowPlayableOnly(!showPlayableOnly);
};
const handleAddGameButtonClick = () => {
setShowAddGameModal(true);
};
const handleCloseAddGameModal = () => {
setShowAddGameModal(false);
};
useEffect(() => {
updateLibrary();
}, [lastPacket?.gameId, updateLibrary]);
@@ -254,15 +265,30 @@ export function Sidebar() {
<small className="sidebar__section-title">
{t("my_library")}
</small>
<button
type="button"
className={cn("sidebar__play-button", {
"sidebar__play-button--active": showPlayableOnly,
})}
onClick={handlePlayButtonClick}
>
<PlayIcon size={16} />
</button>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="sidebar__add-button"
onClick={handleAddGameButtonClick}
data-tooltip-id="add-custom-game-tooltip"
data-tooltip-content={t("add_custom_game_tooltip")}
data-tooltip-place="top"
>
<PlusIcon size={16} />
</button>
<button
type="button"
className={cn("sidebar__play-button", {
"sidebar__play-button--active": showPlayableOnly,
})}
onClick={handlePlayButtonClick}
data-tooltip-id="show-playable-only-tooltip"
data-tooltip-content={t("show_playable_only_tooltip")}
data-tooltip-place="top"
>
<PlayIcon size={16} />
</button>
</div>
</div>
<TextField
@@ -307,6 +333,14 @@ export function Sidebar() {
className="sidebar__handle"
onMouseDown={handleMouseDown}
/>
<SidebarAddingCustomGameModal
visible={showAddGameModal}
onClose={handleCloseAddGameModal}
/>
<Tooltip id="add-custom-game-tooltip" />
<Tooltip id="show-playable-only-tooltip" />
</aside>
);
}

View File

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

View File

@@ -111,6 +111,21 @@ declare global {
objectId: string,
title: string
) => Promise<void>;
addCustomGameToLibrary: (
title: string,
executablePath: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => Promise<Game>;
updateCustomGame: (
shop: GameShop,
objectId: string,
title: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => Promise<Game>;
createGameShortcut: (
shop: GameShop,
objectId: string,

View File

@@ -84,3 +84,23 @@ export const injectCustomCss = (
export const removeCustomCss = (target: HTMLElement = document.head) => {
target.querySelector("#custom-css")?.remove();
};
export const generateRandomGradient = (): string => {
// Use a single consistent gradient with softer colors for custom games as placeholder
const color1 = '#2c3e50'; // Dark blue-gray
const color2 = '#34495e'; // Darker slate
// Create SVG data URL that works in img tags
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#grad)" />
</svg>`;
// Return as data URL that works in img tags
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};

View File

@@ -1,18 +1,20 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { average } from "color.js";
import Color from "color";
import { PencilIcon } from "@primer/octicons-react";
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 { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
@@ -28,11 +30,13 @@ export function GameDetailsContent() {
gameColor,
setGameColor,
hasNSFWContentBlocked,
updateGame,
} = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary } = useLibrary();
const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext);
@@ -53,10 +57,15 @@ export function GameDetailsContent() {
return document.body.outerHTML;
}
if (game?.shop === "custom") {
return "";
}
return t("no_shop_details");
}, [shopDetails, t]);
}, [shopDetails, t, game?.shop]);
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false);
const handleHeroLoad = async () => {
const output = await average(
@@ -92,10 +101,27 @@ export function GameDetailsContent() {
setShowCloudSyncModal(true);
};
const handleEditCustomGameClick = () => {
setShowEditCustomGameModal(true);
};
const handleGameUpdated = (_updatedGame: any) => {
updateGame();
updateLibrary();
};
useEffect(() => {
getGameArtifacts();
}, [getGameArtifacts]);
const isCustomGame = game?.shop === "custom";
const heroImage = isCustomGame
? game?.libraryHeroImageUrl || game?.iconUrl || ""
: shopDetails?.assets?.libraryHeroImageUrl || "";
const logoImage = isCustomGame
? game?.logoImageUrl || "" // Don't use icon as fallback for custom games
: shopDetails?.assets?.logoImageUrl || "";
return (
<div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
@@ -103,7 +129,7 @@ export function GameDetailsContent() {
<section className="game-details__container">
<div ref={heroRef} className="game-details__hero">
<img
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
src={heroImage}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
@@ -121,26 +147,43 @@ export function GameDetailsContent() {
style={{ opacity: backdropOpacity }}
>
<div className="game-details__hero-content">
<img
src={shopDetails?.assets?.logoImageUrl ?? ""}
className="game-details__game-logo"
alt={game?.title}
/>
{logoImage && (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
)}
<button
type="button"
className="game-details__cloud-sync-button"
onClick={handleCloudSaveButtonClick}
>
<div className="game-details__cloud-icon-container">
<img
src={cloudIconAnimated}
alt="Cloud icon"
className="game-details__cloud-icon"
/>
</div>
{t("cloud_save")}
</button>
<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>
)}
{game?.shop !== "custom" && (
<button
type="button"
className="game-details__cloud-sync-button"
onClick={handleCloudSaveButtonClick}
>
<div className="game-details__cloud-icon-container">
<img
src={cloudIconAnimated}
alt="Cloud icon"
className="game-details__cloud-icon"
/>
</div>
{t("cloud_save")}
</button>
)}
</div>
</div>
</div>
</div>
@@ -160,9 +203,18 @@ export function GameDetailsContent() {
/>
</div>
<Sidebar />
{game?.shop !== "custom" && <Sidebar />}
</div>
</section>
{game?.shop === "custom" && (
<EditCustomGameModal
visible={showEditCustomGameModal}
onClose={() => setShowEditCustomGameModal(false)}
game={game}
onGameUpdated={handleGameUpdated}
/>
)}
</div>
);
}

View File

@@ -52,6 +52,43 @@ $hero-height: 300px;
align-items: flex-end;
}
&__hero-buttons {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
&--right {
margin-left: auto;
}
}
&__edit-custom-game-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__hero-logo-backdrop {
width: 100%;
height: 100%;

View File

@@ -17,6 +17,7 @@
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
}
&__manual-warning {

View File

@@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -89,19 +88,23 @@ export function HeroPanelPlaytime() {
return (
<>
<p
<p
className="hero-panel-playtime__play-time"
data-tooltip-place="top"
data-tooltip-place="right"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
data-tooltip-id={
game.hasManuallyUpdatedPlaytime
? "manual-playtime-warning"
: undefined
}
>
{game.hasManuallyUpdatedPlaytime && (
<AlertFillIcon
size={16}
<AlertFillIcon
size={16}
className="hero-panel-playtime__manual-warning"
/>
)}
@@ -119,7 +122,7 @@ export function HeroPanelPlaytime() {
})}
</p>
)}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
@@ -127,7 +130,6 @@ export function HeroPanelPlaytime() {
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</>

View File

@@ -0,0 +1,46 @@
@use "../../../scss/globals.scss";
.edit-custom-game-modal {
&__container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__form {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__image-section {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__image-preview {
display: flex;
justify-content: center;
align-items: center;
padding: globals.$spacing-unit;
border: 1px dashed globals.$border-color;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.05);
}
&__preview-image {
max-width: 120px;
max-height: 80px;
border-radius: 8px;
object-fit: cover;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
justify-content: flex-end;
margin-top: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,311 @@
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 { Game } from "@types";
import "./edit-custom-game-modal.scss";
export interface EditCustomGameModalProps {
visible: boolean;
onClose: () => void;
game: Game;
onGameUpdated: (updatedGame: Game) => void;
}
export function EditCustomGameModal({
visible,
onClose,
game,
onGameUpdated,
}: EditCustomGameModalProps) {
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 || "");
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);
}
}, [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 (!gameName.trim()) {
showErrorToast(t("edit_custom_game_modal_fill_required"));
return;
}
setIsUpdating(true);
try {
// Preserve existing image URLs if not changed
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
const libraryHeroImageUrl = heroPath ? `local:${heroPath}` : game.libraryHeroImageUrl;
const updatedGame = await window.electron.updateCustomGame(
game.shop,
game.objectId,
gameName.trim(),
iconUrl || undefined,
logoImageUrl || undefined,
libraryHeroImageUrl || undefined
);
showSuccessToast(t("edit_custom_game_modal_success"));
onGameUpdated(updatedGame);
onClose();
} catch (error) {
console.error("Failed to update custom game:", error);
showErrorToast(
error instanceof Error
? error.message
: t("edit_custom_game_modal_failed")
);
} finally {
setIsUpdating(false);
}
};
const handleClose = () => {
if (!isUpdating) {
setGameName(game?.title || "");
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);
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-custom-game-modal__container">
<div className="edit-custom-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-custom-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-custom-game-modal__image-preview">
<img
src={getIconPreviewUrl()!}
alt={t("edit_custom_game_modal_icon_preview")}
className="edit-custom-game-modal__preview-image"
/>
</div>
)}
</div>
<div className="edit-custom-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-custom-game-modal__image-preview">
<img
src={getLogoPreviewUrl()!}
alt={t("edit_custom_game_modal_logo_preview")}
className="edit-custom-game-modal__preview-image"
/>
</div>
)}
</div>
<div className="edit-custom-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-custom-game-modal__image-preview">
<img
src={getHeroPreviewUrl()!}
alt={t("edit_custom_game_modal_hero_preview")}
className="edit-custom-game-modal__preview-image"
/>
</div>
)}
</div>
</div>
<div className="edit-custom-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

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

View File

@@ -1,4 +1,4 @@
export type GameShop = "steam" | "epic";
export type GameShop = "steam" | "epic" | "custom";
export type ShortcutLocation = "desktop" | "start_menu";

View File

@@ -33,6 +33,8 @@ export interface User {
export interface Game {
title: string;
iconUrl: string | null;
libraryHeroImageUrl: string | null;
logoImageUrl: string | null;
playTimeInMilliseconds: number;
unsyncedDeltaPlayTimeInMilliseconds?: number;
lastTimePlayed: Date | null;