Feat: Added changing game playtime functionality

This commit is contained in:
Moyasee
2025-09-17 11:17:55 +03:00
parent 01ac5239dc
commit 6ff694c078
14 changed files with 326 additions and 8 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

@@ -198,6 +198,13 @@
"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.",
"change_playtime_title": "Change playtime",
"change_playtime_description": "Manually update the playtime for {{game}}",
"change_playtime": "Change playtime",
"change_playtime_success": "Playtime updated successfully",
"change_playtime_error": "Failed to update playtime",
"change_game_playtime": "Change game playtime",
"manual_playtime_warning": "Your hours will be marked as manually changed, and this cannot be undone.",
"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 +451,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 пока недоступно.",
"change_playtime_title": "Изменить время игры",
"change_playtime_description": "Вручную обновите время игры для {{game}}",
"change_playtime": "Изменить время игры",
"change_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,35 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { gamesSublevel } from "@main/level";
import { levelKeys } from "@main/level";
const changeGamePlaytime = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
playTimeInSeconds: number,
) => {
try {
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
playTimeInSeconds,
});
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
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

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

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

@@ -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,172 @@
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,
}: ChangeGamePlaytimeModalProps) {
const { t } = useTranslation("game_details");
const [hours, setHours] = useState("");
const [minutes, setMinutes] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Prefill current playtime when modal becomes visible
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) {
// Reset to empty if no playtime
setHours("");
setMinutes("");
}
}, [visible, game.playTimeInMilliseconds]);
// Maximum allowed hours (10,000)
const MAX_TOTAL_HOURS = 10000;
// Calculate current total hours including minutes as fractional hours
const currentHours = parseInt(hours) || 0;
const currentMinutes = parseInt(minutes) || 0;
// Calculate maximum allowed values based on current input
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;
// Prevent exceeding 10,000 hours total
if (hoursNum + (minutesNum / 60) > MAX_TOTAL_HOURS) return;
setIsSubmitting(true);
try {
await changePlaytime(totalSeconds);
onSuccess?.(t("change_playtime_success"));
onClose();
} catch (error) {
console.log(error);
onError?.(t("change_playtime_error"));
} finally {
setIsSubmitting(false);
}
};
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
// Remove leading zeros and prevent multiple zeros
if (value.length > 1 && value.startsWith('0')) {
value = value.replace(/^0+/, '') || '0';
}
const numValue = parseInt(value) || 0;
// Don't allow more than the calculated maximum
if (numValue <= maxAllowedHours) {
setHours(value);
}
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
// Remove leading zeros and prevent multiple zeros
if (value.length > 1 && value.startsWith('0')) {
value = value.replace(/^0+/, '') || '0';
}
const numValue = parseInt(value) || 0;
// Don't allow more than the calculated maximum
if (numValue <= maxAllowedMinutes) {
setMinutes(value);
}
};
const isValid = hours !== "" || minutes !== "";
return (
<Modal
visible={visible}
onClose={onClose}
title={t("change_playtime_title")}
description={t("change_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("change_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,16 @@ export function GameOptionsModal({
}
};
const handleChangePlaytime = async (playtimeInSeconds: number) => {
try {
await window.electron.changeGamePlayTime(game.shop, game.objectId, playtimeInSeconds);
await updateGame();
showSuccessToast(t("change_playtime_success"));
} catch (error) {
showErrorToast(t("change_playtime_error"));
}
};
const handleToggleAutomaticCloudSync = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
@@ -264,6 +276,13 @@ export function GameOptionsModal({
game={game}
/>
<ChangeGamePlaytimeModal
visible={showChangePlaytimeModal}
onClose={() => setShowChangePlaytimeModal(false)}
changePlaytime={handleChangePlaytime}
game={game}
/>
<Modal
visible={visible}
title={game.title}
@@ -476,6 +495,14 @@ export function GameOptionsModal({
{t("reset_achievements")}
</Button>
<Button
onClick={() => setShowChangePlaytimeModal(true)}
theme="danger"
>
{t("change_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,11 +85,13 @@ export function UserLibraryGameCard({
);
return (
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
title={game.title}
title={isTooltipHovered ? undefined : game.title}
>
<button
type="button"
@@ -95,9 +99,17 @@ export function UserLibraryGameCard({
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
<small className="user-library-game__playtime">
<ClockIcon size={11} />
<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 && (
@@ -155,5 +167,16 @@ export function UserLibraryGameCard({
/>
</button>
</li>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
/>
</>
);
}

View File

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