Feat: Added changing game playtime functionality

This commit is contained in:
Moyasee
2025-09-17 11:24:24 +03:00
parent 6ff694c078
commit 86da92aa3f
7 changed files with 137 additions and 118 deletions

View File

@@ -4,13 +4,12 @@ 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,
) => {
playTimeInSeconds: number
) => {
try {
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
playTimeInSeconds,
@@ -26,10 +25,6 @@ const changeGamePlaytime = async (
} catch (error) {
throw new Error(`Failed to update game favorite status: ${error}`);
}
;
};
registerEvent("changeGamePlayTime", changeGamePlaytime);

View File

@@ -162,7 +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>;
changeGamePlayTime: (
shop: GameShop,
objectId: string,
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;

View File

@@ -36,4 +36,4 @@
align-items: center;
gap: globals.$spacing-unit;
}
}
}

View File

@@ -30,10 +30,12 @@ export function ChangeGamePlaytimeModal({
// Prefill current playtime when modal becomes visible
useEffect(() => {
if (visible && game.playTimeInMilliseconds) {
const totalMinutes = Math.floor(game.playTimeInMilliseconds / (1000 * 60));
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) {
@@ -51,19 +53,24 @@ export function ChangeGamePlaytimeModal({
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 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);
const totalSeconds = hoursNum * 3600 + minutesNum * 60;
if (totalSeconds < 0) return;
// Prevent exceeding 10,000 hours total
if (hoursNum + (minutesNum / 60) > MAX_TOTAL_HOURS) return;
if (hoursNum + minutesNum / 60 > MAX_TOTAL_HOURS) return;
setIsSubmitting(true);
try {
@@ -80,14 +87,14 @@ export function ChangeGamePlaytimeModal({
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';
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);
@@ -96,14 +103,14 @@ export function ChangeGamePlaytimeModal({
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';
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);
@@ -128,7 +135,7 @@ export function ChangeGamePlaytimeModal({
<span>{t("manual_playtime_warning")}</span>
</div>
)}
<div className="change-game-playtime-modal__inputs">
<TextField
label={t("hours")}
@@ -153,8 +160,8 @@ export function ChangeGamePlaytimeModal({
</div>
<div className="change-game-playtime-modal__actions">
<Button
onClick={handleChangePlaytime}
<Button
onClick={handleChangePlaytime}
theme="outline"
disabled={!isValid || isSubmitting}
>
@@ -169,4 +176,3 @@ export function ChangeGamePlaytimeModal({
</Modal>
);
}

View File

@@ -232,7 +232,11 @@ export function GameOptionsModal({
const handleChangePlaytime = async (playtimeInSeconds: number) => {
try {
await window.electron.changeGamePlayTime(game.shop, game.objectId, playtimeInSeconds);
await window.electron.changeGamePlayTime(
game.shop,
game.objectId,
playtimeInSeconds
);
await updateGame();
showSuccessToast(t("change_playtime_success"));
} catch (error) {
@@ -498,7 +502,6 @@ export function GameOptionsModal({
<Button
onClick={() => setShowChangePlaytimeModal(true)}
theme="danger"
>
{t("change_game_playtime")}
</Button>

View File

@@ -9,7 +9,7 @@ import {
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { ClockIcon, TrophyIcon, AlertFillIcon} 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";
@@ -85,98 +85,108 @@ export function UserLibraryGameCard({
);
return (
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
title={isTooltipHovered ? undefined : 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" 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>
<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>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
/>
<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 {