mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Feat: Added changing game playtime functionality
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отображаемое имя",
|
||||
|
||||
@@ -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";
|
||||
|
||||
35
src/main/events/library/change-game-playtime.ts
Normal file
35
src/main/events/library/change-game-playtime.ts
Normal 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);
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
1
src/renderer/src/declaration.d.ts
vendored
1
src/renderer/src/declaration.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -75,7 +75,9 @@
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__manual-playtime {
|
||||
color: globals.$warning-color;
|
||||
}
|
||||
&__stats {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface Game {
|
||||
launchOptions?: string | null;
|
||||
favorite?: boolean;
|
||||
automaticCloudSync?: boolean;
|
||||
hasManuallyUpdatedPlaytime?: boolean;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
|
||||
Reference in New Issue
Block a user