diff --git a/.env.example b/.env.example index 86a515f6..3f914eb3 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 96f72e1b..d191620c 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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.", + "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.", "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", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index c7857e81..e576e5d9 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -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": "Отображаемое имя", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index d640e251..9765b517 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts new file mode 100644 index 00000000..f2c4d670 --- /dev/null +++ b/src/main/events/library/change-game-playtime.ts @@ -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); diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index b9d10f52..a35414ac 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -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, }); diff --git a/src/preload/index.ts b/src/preload/index.ts index ab2beae2..d29417b0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: () => diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 7b2f9412..0744884c 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -162,6 +162,11 @@ declare global { ) => () => Electron.IpcRenderer; onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; resetGameAchievements: (shop: GameShop, objectId: string) => Promise; + changeGamePlayTime: ( + shop: GameShop, + objectId: string, + playtimeInSeconds: number + ) => Promise; /* User preferences */ authenticateRealDebrid: (apiToken: string) => Promise; authenticateTorBox: (apiToken: string) => Promise; diff --git a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.scss b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.scss new file mode 100644 index 00000000..41263dbc --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx new file mode 100644 index 00000000..c9d26b94 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx @@ -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; + onSuccess?: (message: string) => void; + onError?: (message: string) => void; +} + +export function ChangeGamePlaytimeModal({ + onClose, + game, + visible, + changePlaytime, + onSuccess, + onError, +}: Readonly) { + 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) => { + 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) => { + 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 ( + +
+ {!game.hasManuallyUpdatedPlaytime && ( +
+ + {t("manual_playtime_warning")} +
+ )} + +
+ + +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 4c415f7b..9c20acce 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -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 ) => { @@ -264,6 +280,13 @@ export function GameOptionsModal({ game={game} /> + setShowChangePlaytimeModal(false)} + changePlaytime={handleChangePlaytime} + game={game} + /> + + + - + {game.title} + + + setIsTooltipHovered(true)} + afterHide={() => setIsTooltipHovered(false)} + /> + ); } diff --git a/src/types/index.ts b/src/types/index.ts index 2c0a75cf..03075e2e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,6 +70,7 @@ export type UserGame = { unlockedAchievementCount: number; achievementCount: number; achievementsPointsEarnedSum: number; + hasManuallyUpdatedPlaytime: boolean; } & ShopAssets; export interface GameRunning { diff --git a/src/types/level.types.ts b/src/types/level.types.ts index e99641fe..d749e777 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -45,6 +45,7 @@ export interface Game { launchOptions?: string | null; favorite?: boolean; automaticCloudSync?: boolean; + hasManuallyUpdatedPlaytime?: boolean; } export interface Download {