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 { gamesSublevel } from "@main/level";
import { levelKeys } from "@main/level"; import { levelKeys } from "@main/level";
const changeGamePlaytime = async ( const changeGamePlaytime = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
playTimeInSeconds: number, playTimeInSeconds: number
) => { ) => {
try { try {
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, { await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
playTimeInSeconds, playTimeInSeconds,
@@ -26,10 +25,6 @@ const changeGamePlaytime = async (
} catch (error) { } catch (error) {
throw new Error(`Failed to update game favorite status: ${error}`); throw new Error(`Failed to update game favorite status: ${error}`);
} }
;
}; };
registerEvent("changeGamePlayTime", changeGamePlaytime); registerEvent("changeGamePlayTime", changeGamePlaytime);

View File

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

View File

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

View File

@@ -30,10 +30,12 @@ export function ChangeGamePlaytimeModal({
// Prefill current playtime when modal becomes visible // Prefill current playtime when modal becomes visible
useEffect(() => { useEffect(() => {
if (visible && game.playTimeInMilliseconds) { 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 currentHours = Math.floor(totalMinutes / 60);
const currentMinutes = totalMinutes % 60; const currentMinutes = totalMinutes % 60;
setHours(currentHours.toString()); setHours(currentHours.toString());
setMinutes(currentMinutes.toString()); setMinutes(currentMinutes.toString());
} else if (visible) { } else if (visible) {
@@ -51,19 +53,24 @@ export function ChangeGamePlaytimeModal({
const currentMinutes = parseInt(minutes) || 0; const currentMinutes = parseInt(minutes) || 0;
// Calculate maximum allowed values based on current input // Calculate maximum allowed values based on current input
const maxAllowedHours = Math.min(MAX_TOTAL_HOURS, Math.floor(MAX_TOTAL_HOURS - (currentMinutes / 60))); const maxAllowedHours = Math.min(
const maxAllowedMinutes = currentHours >= MAX_TOTAL_HOURS ? 0 : Math.min(59, Math.floor((MAX_TOTAL_HOURS - currentHours) * 60)); 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 handleChangePlaytime = async () => {
const hoursNum = parseInt(hours) || 0; const hoursNum = parseInt(hours) || 0;
const minutesNum = parseInt(minutes) || 0; const minutesNum = parseInt(minutes) || 0;
const totalSeconds = (hoursNum * 3600) + (minutesNum * 60); const totalSeconds = hoursNum * 3600 + minutesNum * 60;
if (totalSeconds < 0) return; if (totalSeconds < 0) return;
// Prevent exceeding 10,000 hours total // Prevent exceeding 10,000 hours total
if (hoursNum + (minutesNum / 60) > MAX_TOTAL_HOURS) return; if (hoursNum + minutesNum / 60 > MAX_TOTAL_HOURS) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
@@ -80,14 +87,14 @@ export function ChangeGamePlaytimeModal({
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value; let value = e.target.value;
// Remove leading zeros and prevent multiple zeros // Remove leading zeros and prevent multiple zeros
if (value.length > 1 && value.startsWith('0')) { if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, '') || '0'; value = value.replace(/^0+/, "") || "0";
} }
const numValue = parseInt(value) || 0; const numValue = parseInt(value) || 0;
// Don't allow more than the calculated maximum // Don't allow more than the calculated maximum
if (numValue <= maxAllowedHours) { if (numValue <= maxAllowedHours) {
setHours(value); setHours(value);
@@ -96,14 +103,14 @@ export function ChangeGamePlaytimeModal({
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value; let value = e.target.value;
// Remove leading zeros and prevent multiple zeros // Remove leading zeros and prevent multiple zeros
if (value.length > 1 && value.startsWith('0')) { if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, '') || '0'; value = value.replace(/^0+/, "") || "0";
} }
const numValue = parseInt(value) || 0; const numValue = parseInt(value) || 0;
// Don't allow more than the calculated maximum // Don't allow more than the calculated maximum
if (numValue <= maxAllowedMinutes) { if (numValue <= maxAllowedMinutes) {
setMinutes(value); setMinutes(value);
@@ -128,7 +135,7 @@ export function ChangeGamePlaytimeModal({
<span>{t("manual_playtime_warning")}</span> <span>{t("manual_playtime_warning")}</span>
</div> </div>
)} )}
<div className="change-game-playtime-modal__inputs"> <div className="change-game-playtime-modal__inputs">
<TextField <TextField
label={t("hours")} label={t("hours")}
@@ -153,8 +160,8 @@ export function ChangeGamePlaytimeModal({
</div> </div>
<div className="change-game-playtime-modal__actions"> <div className="change-game-playtime-modal__actions">
<Button <Button
onClick={handleChangePlaytime} onClick={handleChangePlaytime}
theme="outline" theme="outline"
disabled={!isValid || isSubmitting} disabled={!isValid || isSubmitting}
> >
@@ -169,4 +176,3 @@ export function ChangeGamePlaytimeModal({
</Modal> </Modal>
); );
} }

View File

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

View File

@@ -9,7 +9,7 @@ import {
formatDownloadProgress, formatDownloadProgress,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { userProfileContext } from "@renderer/context"; 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 { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip"; import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -85,98 +85,108 @@ export function UserLibraryGameCard({
); );
return ( return (
<> <>
<li
<li onMouseEnter={onMouseEnter}
onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
onMouseLeave={onMouseLeave} className="user-library-game__wrapper"
className="user-library-game__wrapper" title={isTooltipHovered ? undefined : game.title}
title={isTooltipHovered ? undefined : game.title}
>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
> >
<div className="user-library-game__overlay"> <button
type="button"
<small className="user-library-game__playtime" data-tooltip-place="top" className="user-library-game__cover"
data-tooltip-content={game.hasManuallyUpdatedPlaytime ? t("manual_playtime_tooltip") : undefined} onClick={() => navigate(buildUserGameDetailsPath(game))}
data-tooltip-id={game.objectId} > >
{game.hasManuallyUpdatedPlaytime ? ( <div className="user-library-game__overlay">
<AlertFillIcon size={11} className="user-library-game__manual-playtime" /> <small
) : ( className="user-library-game__playtime"
<ClockIcon size={11} /> data-tooltip-place="top"
)} data-tooltip-content={
{formatPlayTime(game.playTimeInSeconds)} game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
</small> : 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> <span>
{game.unlockedAchievementCount} / {game.achievementCount} {formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span> </span>
</div> </div>
{game.achievementsPointsEarnedSum > 0 && ( <progress
<div max={1}
className="user-library-game__stats-item" value={
style={{ game.unlockedAchievementCount / game.achievementCount
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`, }
}} className="user-library-game__achievements-progress"
> />
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div> </div>
)}
</div>
<span> <img
{formatDownloadProgress( src={game.coverImageUrl}
game.unlockedAchievementCount / game.achievementCount, alt={game.title}
1 className="user-library-game__game-image"
)} />
</span> </button>
</div> </li>
<Tooltip
<progress id={game.objectId}
max={1} style={{
value={game.unlockedAchievementCount / game.achievementCount} zIndex: 9999,
className="user-library-game__achievements-progress" }}
/> openOnClick={false}
</div> afterShow={() => setIsTooltipHovered(true)}
)} afterHide={() => setIsTooltipHovered(false)}
</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)}
/>
</> </>
); );
} }

View File

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