Compare commits

..

17 Commits

Author SHA1 Message Date
Moyase
0adea20565 Merge branch 'main' into feat/playtime-changing 2025-09-17 13:57:10 +03:00
Moyasee
f182c7c8e9 Feat: added warning to the hero-panel-playtime 2025-09-17 13:49:05 +03:00
Chubby Granny Chaser
d9379fbcb9 Merge pull request #1782 from hydralauncher/feat/playtime-changing
Feature: Playtime Changing
2025-09-17 10:53:38 +01:00
Moyasee
adc4af731e Fix: Fixed import errors in change-game-playtime.ts 2025-09-17 12:41:41 +03:00
Moyasee
af1b3d4535 Fix: Updated EN and RU translations to use correct keywords 2025-09-17 12:35:46 +03:00
Moyasee
291935a1bc Fix: Changed ChangeGamePlaytimeModalProps to Readonly 2025-09-17 12:20:52 +03:00
Moyasee
665365abdc Deleted logs, comments. Fixed change-game-playtime event. 2025-09-17 12:14:51 +03:00
Moyasee
86da92aa3f Feat: Added changing game playtime functionality 2025-09-17 11:24:24 +03:00
Moyasee
6ff694c078 Feat: Added changing game playtime functionality 2025-09-17 11:17:55 +03:00
Zamitto
01ac5239dc fix: steam user path not found
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-06 12:34:07 -03:00
Zamitto
1dc2176813 chore: bump version
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-03 06:52:44 -03:00
Zamitto
a7ec632a21 fix: window not opening sometimes when clicking on tray 2025-09-03 06:49:26 -03:00
Zamitto
2b6d8eba78 fix: window not opening when clicking on tray icon on linux 2025-09-03 06:05:02 -03:00
Zamitto
6bc6a10d66 chore: testing aur package pipeline
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-02 06:26:28 -03:00
Zamitto
51f8b12e13 chore: bump version 2025-09-02 05:59:34 -03:00
Zamitto
957a6b512e fix: handle case where steam is not installed 2025-09-02 05:59:15 -03:00
Zamitto
8bc1c1c58c fix: handle error on getting steam path from windows registry
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-01 21:41:18 -03:00
25 changed files with 488 additions and 88 deletions

View File

@@ -1,5 +1,5 @@
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_API_URL=
MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.6.4",
"version": "3.6.8",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",

View File

@@ -198,6 +198,14 @@
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
"download_error_not_cached_on_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
"update_playtime_title": "Update playtime",
"update_playtime_description": "Manually update the playtime for {{game}}",
"update_playtime": "Update playtime",
"update_playtime_success": "Playtime updated successfully",
"update_playtime_error": "Failed to update playtime",
"update_game_playtime": "Update game playtime",
"manual_playtime_warning": "Your hours will be marked as manually updated, and this cannot be undone.",
"manual_playtime_tooltip": "This playtime has been manually updated",
"download_error_not_cached_on_torbox": "This download is not available on TorBox and polling download status from TorBox is not yet available.",
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
"game_removed_from_favorites": "Game removed from favorites",
@@ -444,6 +452,7 @@
"activity": "Recent Activity",
"library": "Library",
"total_play_time": "Total playtime",
"manual_playtime_tooltip": "This playtime has been manually updated",
"no_recent_activity_title": "Hmmm… nothing here",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
"display_name": "Display name",

View File

@@ -195,6 +195,10 @@
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
"download_error_not_cached_on_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
"update_playtime_title": "Обновить время игры",
"update_playtime_description": "Вручную обновите время игры для {{game}}",
"update_playtime": "Обновить время игры",
"update_game_playtime": "Обновить время игры",
"download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_favorites": "Игра удалена из избранного",
@@ -428,6 +432,7 @@
"activity": "Недавняя активность",
"library": "Библиотека",
"total_play_time": "Всего сыграно",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"no_recent_activity_title": "Хммм... Тут ничего нет",
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя",

View File

@@ -33,6 +33,7 @@ import "./library/remove-game";
import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements";
import "./library/change-game-playtime";
import "./library/toggle-automatic-cloud-sync";
import "./library/get-default-wine-prefix-selection-path";
import "./library/create-steam-shortcut";

View File

@@ -0,0 +1,29 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
const changeGamePlaytime = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
playTimeInSeconds: number
) => {
try {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
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}`);
}
};
registerEvent("changeGamePlayTime", changeGamePlaytime);

View File

@@ -94,7 +94,7 @@ const createSteamShortcut = async (
if (!steamUserIds.length) {
logger.error("No Steam user ID found");
return;
throw new Error("No Steam user ID found");
}
const [iconImage, heroImage, logoImage, coverImage, libraryImage] =

View File

@@ -4,7 +4,6 @@ import { Downloader } from "@shared";
import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types";
import {
WSClient,
SystemPath,
CommonRedistManager,
TorBoxClient,
@@ -47,7 +46,7 @@ export const loadState = async () => {
await HydraApi.setupApi().then(() => {
uploadGamesBatch();
WSClient.connect();
// WSClient.connect();
});
const downloads = await downloadsSublevel

View File

@@ -279,13 +279,17 @@ export const findAchievementFiles = (game: Game) => {
};
const steamUserIds = await getSteamUsersIds();
const steamPath = await getSteamLocation();
const steamPath = await getSteamLocation().catch(() => null);
export const findAchievementFileInSteamPath = async (game: Game) => {
if (!steamUserIds.length) {
return [];
}
if (!steamPath) {
return [];
}
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{

View File

@@ -11,7 +11,6 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { WSClient } from "./ws/ws-client";
interface HydraApiOptions {
needsAuth?: boolean;
@@ -103,8 +102,8 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds();
uploadGamesBatch();
WSClient.close();
WSClient.connect();
// WSClient.close();
// WSClient.connect();
}
}

View File

@@ -6,6 +6,7 @@ type ProfileGame = {
id: string;
lastTimePlayed: Date | null;
playTimeInMilliseconds: number;
hasManuallyUpdatedPlaytime: boolean;
isFavorite?: boolean;
} & ShopAssets;
@@ -45,6 +46,7 @@ export const mergeWithRemoteGames = async () => {
iconUrl: game.iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
isDeleted: false,
favorite: game.isFavorite ?? false,
});

View File

@@ -42,6 +42,10 @@ export const getSteamLocation = async () => {
reject(err);
}
if (!value) {
reject(new Error("SteamPath not found in registry"));
}
resolve(value.value);
});
});
@@ -78,9 +82,19 @@ export const getSteamAppDetails = async (
};
export const getSteamUsersIds = async () => {
const userDataPath = await getSteamLocation();
const steamLocation = await getSteamLocation().catch(() => null);
const userIds = fs.readdirSync(path.join(userDataPath, "userdata"), {
if (!steamLocation) {
return [];
}
const userDataPath = path.join(steamLocation, "userdata");
if (!fs.existsSync(userDataPath)) {
return [];
}
const userIds = fs.readdirSync(userDataPath, {
withFileTypes: true,
});

View File

@@ -181,6 +181,9 @@ export class WindowManager {
});
this.mainWindow.on("close", async () => {
const mainWindow = this.mainWindow;
this.mainWindow = null;
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
@@ -188,9 +191,11 @@ export class WindowManager {
}
);
if (this.mainWindow) {
const lastBounds = this.mainWindow.getBounds();
const isMaximized = this.mainWindow.isMaximized() ?? false;
if (mainWindow) {
mainWindow.setProgressBar(-1);
const lastBounds = mainWindow.getBounds();
const isMaximized = mainWindow.isMaximized() ?? false;
const screenConfig = isMaximized
? {
x: undefined,
@@ -207,9 +212,6 @@ export class WindowManager {
if (userPreferences?.preferQuitInsteadOfHiding) {
app.quit();
}
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow = null;
});
this.mainWindow.webContents.setWindowOpenHandler((handler) => {
@@ -584,7 +586,7 @@ export class WindowManager {
tray.setToolTip("Hydra Launcher");
if (process.platform !== "darwin") {
if (process.platform === "win32") {
await updateSystemTray();
tray.addListener("double-click", () => {
@@ -595,6 +597,18 @@ export class WindowManager {
}
});
tray.addListener("right-click", showContextMenu);
} else if (process.platform === "linux") {
await updateSystemTray();
tray.addListener("click", () => {
if (this.mainWindow) {
this.mainWindow.show();
} else {
this.createMainWindow();
}
});
tray.addListener("right-click", showContextMenu);
} else {
tray.addListener("click", showContextMenu);

View File

@@ -189,6 +189,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
extractGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("extractGameDownload", shop, objectId),
getDefaultWinePrefixSelectionPath: () =>

View File

@@ -162,6 +162,11 @@ declare global {
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
changeGamePlayTime: (
shop: GameShop,
objectId: string,
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;

View File

@@ -41,7 +41,9 @@ export function GameItem({ game }: GameItemProps) {
setAdded(exists);
}, [library, game.shop, game.objectId]);
const addGameToLibrary = async (event: React.MouseEvent | React.KeyboardEvent) => {
const addGameToLibrary = async (
event: React.MouseEvent | React.KeyboardEvent
) => {
event.stopPropagation();
if (added || isAddingToLibrary) return;

View File

@@ -12,4 +12,15 @@
color: globals.$body-color;
text-decoration: underline;
}
&__play-time {
display: flex;
align-items: center;
gap: 8px;
}
&__manual-warning {
color: #f59e0b; // Warning amber color
flex-shrink: 0;
}
}

View File

@@ -5,10 +5,14 @@ import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { AlertFillIcon } from "@primer/octicons-react";
import { Tooltip } from "react-tooltip";
import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
@@ -85,7 +89,22 @@ export function HeroPanelPlaytime() {
return (
<>
<p>
<p
className="hero-panel-playtime__play-time"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
>
{game.hasManuallyUpdatedPlaytime && (
<AlertFillIcon
size={16}
className="hero-panel-playtime__manual-warning"
/>
)}
{t("play_time", {
amount: formattedPlayTime,
})}
@@ -100,6 +119,17 @@ export function HeroPanelPlaytime() {
})}
</p>
)}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
style={{
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</>
);
}

View File

@@ -0,0 +1,39 @@
@use "../../../scss/globals.scss";
.change-game-playtime-modal {
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit * 2;
}
&__warning {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit;
background-color: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
color: #ffc107;
font-size: 14px;
svg {
flex-shrink: 0;
}
}
&__inputs {
display: flex;
gap: globals.$spacing-unit;
align-items: flex-end;
}
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,168 @@
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
import { useState, useEffect } from "react";
import { AlertIcon } from "@primer/octicons-react";
import "./change-game-playtime-modal.scss";
export interface ChangeGamePlaytimeModalProps {
visible: boolean;
game: Game;
onClose: () => void;
changePlaytime: (playTimeInSeconds: number) => Promise<void>;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
}
export function ChangeGamePlaytimeModal({
onClose,
game,
visible,
changePlaytime,
onSuccess,
onError,
}: Readonly<ChangeGamePlaytimeModalProps>) {
const { t } = useTranslation("game_details");
const [hours, setHours] = useState("");
const [minutes, setMinutes] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (visible && game.playTimeInMilliseconds) {
const totalMinutes = Math.floor(
game.playTimeInMilliseconds / (1000 * 60)
);
const currentHours = Math.floor(totalMinutes / 60);
const currentMinutes = totalMinutes % 60;
setHours(currentHours.toString());
setMinutes(currentMinutes.toString());
} else if (visible) {
setHours("");
setMinutes("");
}
}, [visible, game.playTimeInMilliseconds]);
const MAX_TOTAL_HOURS = 10000;
const currentHours = parseInt(hours) || 0;
const currentMinutes = parseInt(minutes) || 0;
const maxAllowedHours = Math.min(
MAX_TOTAL_HOURS,
Math.floor(MAX_TOTAL_HOURS - currentMinutes / 60)
);
const maxAllowedMinutes =
currentHours >= MAX_TOTAL_HOURS
? 0
: Math.min(59, Math.floor((MAX_TOTAL_HOURS - currentHours) * 60));
const handleChangePlaytime = async () => {
const hoursNum = parseInt(hours) || 0;
const minutesNum = parseInt(minutes) || 0;
const totalSeconds = hoursNum * 3600 + minutesNum * 60;
if (totalSeconds < 0) return;
if (hoursNum + minutesNum / 60 > MAX_TOTAL_HOURS) return;
setIsSubmitting(true);
try {
await changePlaytime(totalSeconds);
onSuccess?.(t("update_playtime_success"));
onClose();
} catch (error) {
console.log(error);
onError?.(t("update_playtime_error"));
} finally {
setIsSubmitting(false);
}
};
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "") || "0";
}
const numValue = parseInt(value) || 0;
if (numValue <= maxAllowedHours) {
setHours(value);
}
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "") || "0";
}
const numValue = parseInt(value) || 0;
if (numValue <= maxAllowedMinutes) {
setMinutes(value);
}
};
const isValid = hours !== "" || minutes !== "";
return (
<Modal
visible={visible}
onClose={onClose}
title={t("update_playtime_title")}
description={t("update_playtime_description", {
game: game.title,
})}
>
<div className="change-game-playtime-modal__content">
{!game.hasManuallyUpdatedPlaytime && (
<div className="change-game-playtime-modal__warning">
<AlertIcon size={16} />
<span>{t("manual_playtime_warning")}</span>
</div>
)}
<div className="change-game-playtime-modal__inputs">
<TextField
label={t("hours")}
type="number"
min="0"
max={maxAllowedHours.toString()}
value={hours}
onChange={handleHoursChange}
placeholder="0"
theme="dark"
/>
<TextField
label={t("minutes")}
type="number"
min="0"
max={maxAllowedMinutes.toString()}
value={minutes}
onChange={handleMinutesChange}
placeholder="0"
theme="dark"
/>
</div>
<div className="change-game-playtime-modal__actions">
<Button
onClick={handleChangePlaytime}
theme="outline"
disabled={!isValid || isSubmitting}
>
{t("update_playtime")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -7,6 +7,7 @@ import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { ResetAchievementsModal } from "./reset-achievements-modal";
import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { debounce } from "lodash-es";
@@ -43,6 +44,7 @@ export function GameOptionsModal({
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
const [showResetAchievementsModal, setShowResetAchievementsModal] =
useState(false);
const [showChangePlaytimeModal, setShowChangePlaytimeModal] = useState(false);
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
const [automaticCloudSync, setAutomaticCloudSync] = useState(
game.automaticCloudSync ?? false
@@ -228,6 +230,20 @@ export function GameOptionsModal({
}
};
const handleChangePlaytime = async (playtimeInSeconds: number) => {
try {
await window.electron.changeGamePlayTime(
game.shop,
game.objectId,
playtimeInSeconds
);
await updateGame();
showSuccessToast(t("update_playtime_success"));
} catch (error) {
showErrorToast(t("update_playtime_error"));
}
};
const handleToggleAutomaticCloudSync = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
@@ -264,6 +280,13 @@ export function GameOptionsModal({
game={game}
/>
<ChangeGamePlaytimeModal
visible={showChangePlaytimeModal}
onClose={() => setShowChangePlaytimeModal(false)}
changePlaytime={handleChangePlaytime}
game={game}
/>
<Modal
visible={visible}
title={game.title}
@@ -476,6 +499,13 @@ export function GameOptionsModal({
{t("reset_achievements")}
</Button>
<Button
onClick={() => setShowChangePlaytimeModal(true)}
theme="danger"
>
{t("update_game_playtime")}
</Button>
<Button
onClick={() => {
setShowDeleteModal(true);

View File

@@ -75,7 +75,9 @@
gap: 4px;
padding: 4px;
}
&__manual-playtime {
color: globals.$warning-color;
}
&__stats {
width: 100%;
display: flex;

View File

@@ -2,15 +2,16 @@ import { UserGame } from "@types";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { useCallback, useContext } from "react";
import { useCallback, useContext, useState } from "react";
import {
buildGameAchievementPath,
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { ClockIcon, TrophyIcon, AlertFillIcon } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next";
import "./user-library-game-card.scss";
@@ -31,6 +32,7 @@ export function UserLibraryGameCard({
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const getStatsItemCount = useCallback(() => {
let statsCount = 1;
@@ -83,77 +85,108 @@ export function UserLibraryGameCard({
);
return (
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
title={game.title}
>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
title={isTooltipHovered ? undefined : game.title}
>
<div className="user-library-game__overlay">
<small className="user-library-game__playtime">
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
<small
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
{formatPlayTime(game.playTimeInSeconds)}
</small>
{userProfile?.hasActiveSubscription &&
game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} / {game.achievementCount}
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
<progress
max={1}
value={
game.unlockedAchievementCount / game.achievementCount
}
className="user-library-game__achievements-progress"
/>
</div>
)}
</div>
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className="user-library-game__achievements-progress"
/>
</div>
)}
</div>
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
/>
</button>
</li>
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
/>
</button>
</li>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
/>
</>
);
}

View File

@@ -70,6 +70,7 @@ export type UserGame = {
unlockedAchievementCount: number;
achievementCount: number;
achievementsPointsEarnedSum: number;
hasManuallyUpdatedPlaytime: boolean;
} & ShopAssets;
export interface GameRunning {

View File

@@ -45,6 +45,7 @@ export interface Game {
launchOptions?: string | null;
favorite?: boolean;
automaticCloudSync?: boolean;
hasManuallyUpdatedPlaytime?: boolean;
}
export interface Download {