Compare commits

...

20 Commits

Author SHA1 Message Date
Zamitto
ba2ac1eb93 Merge pull request #1364 from hydralauncher/feat/adjust-tray-icon-interaction
feat: double click shows windows
2024-12-31 00:12:45 -03:00
Zamitto
726d99a5c7 chore: bump version 2024-12-30 23:06:23 -03:00
Zamitto
402e5df9ac feat: double click shows windows 2024-12-30 22:38:16 -03:00
Zamitto
0ee55b7fd5 Merge pull request #1362 from bankov4eto/main
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
PR:[translation] Bulgarian language add missing strings
2024-12-30 15:27:22 -03:00
bankov4eto
8286390d9f Update translation.json
Added missing translation for the new strings 
{
  "catalogue": {
    "search": "Filter…",
    "developers": "Developers",
    "genres": "Genres",
    "tags": "Tags",
    "publishers": "Publishers",
    "download_sources": "Download sources",
    "result_count": "{{resultCount}} results",
    "filter_count": "{{filterCount}} available",
    "clear_filters": "Clear {{filterCount}} selected"
  },
  "game_details": {
    "launch_options": "Launch Options",
    "launch_options_description": "Advanced users may choose to enter modifications to their launch options",
    "launch_options_placeholder": "No parameter specified"
  },
  "downloads": {
    "seeding": "Seeding",
    "stop_seeding": "Stop seeding",
    "resume_seeding": "Resume seeding",
    "options": "Manage"
  },
  "settings": {
    "seed_after_download_complete": "Seed after download complete",
    "show_hidden_achievement_description": "Show hidden achievements description before unlocking them"
  },
  "user_profile": {
    "stats": "Stats",
    "achievements": "achievements",
    "games": "Games",
    "top_percentile": "Top {{percentile}}%",
    "ranking_updated_weekly": "Ranking is updated weekly",
    "playing": "Playing {{game}}",
    "achievements_unlocked": "Achievements Unlocked",
    "earned_points": "Earned points",
    "show_achievements_on_profile": "Show your achievements on your profile",
    "show_points_on_profile": "Show your earned points on your profile"
  },
  "achievement": {
    "hidden_achievement_tooltip": "This is a hidden achievement",
    "achievement_earn_points": "Earn {{points}} points with this achievement",
    "earned_points": "Earned points:",
    "available_points": "Available points:",
    "how_to_earn_achievements_points": "How to earn achievements points?"
  },
  "hydra_cloud": {
    "hydra_cloud": "Hydra Cloud",
    "hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
    "learn_more": "Learn More"
  }
}
2024-12-29 13:30:07 +02:00
bankov4eto
6e00fb8e13 Update translation.json 2024-12-29 12:47:01 +02:00
bankov4eto
9f5d8cadda Update translation.json
Added missing translation for the new strings 
{
  "catalogue": {
    "search": "Filter…",
    "developers": "Developers",
    "genres": "Genres",
    "tags": "Tags",
    "publishers": "Publishers",
    "download_sources": "Download sources",
    "result_count": "{{resultCount}} results",
    "filter_count": "{{filterCount}} available",
    "clear_filters": "Clear {{filterCount}} selected"
  },
  "game_details": {
    "launch_options": "Launch Options",
    "launch_options_description": "Advanced users may choose to enter modifications to their launch options",
    "launch_options_placeholder": "No parameter specified"
  },
  "downloads": {
    "seeding": "Seeding",
    "stop_seeding": "Stop seeding",
    "resume_seeding": "Resume seeding",
    "options": "Manage"
  },
  "settings": {
    "seed_after_download_complete": "Seed after download complete",
    "show_hidden_achievement_description": "Show hidden achievements description before unlocking them"
  },
  "user_profile": {
    "stats": "Stats",
    "achievements": "achievements",
    "games": "Games",
    "top_percentile": "Top {{percentile}}%",
    "ranking_updated_weekly": "Ranking is updated weekly",
    "playing": "Playing {{game}}",
    "achievements_unlocked": "Achievements Unlocked",
    "earned_points": "Earned points",
    "show_achievements_on_profile": "Show your achievements on your profile",
    "show_points_on_profile": "Show your earned points on your profile"
  },
  "achievement": {
    "hidden_achievement_tooltip": "This is a hidden achievement",
    "achievement_earn_points": "Earn {{points}} points with this achievement",
    "earned_points": "Earned points:",
    "available_points": "Available points:",
    "how_to_earn_achievements_points": "How to earn achievements points?"
  },
  "hydra_cloud": {
    "hydra_cloud": "Hydra Cloud",
    "hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
    "learn_more": "Learn More"
  }
}
2024-12-29 12:42:27 +02:00
Zamitto
9060d435cf Merge pull request #1355 from dvsouto/feat/launch-options
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
feat: add custom launch options to game
2024-12-28 13:14:32 -03:00
Zamitto
d8e7fca224 Merge branch 'main' into feat/launch-options 2024-12-28 12:50:38 -03:00
Zamitto
3bef2633fd Merge pull request #1354 from hydralauncher/feat/game-card-animation
feat: game card stats animation
2024-12-28 12:50:02 -03:00
Zamitto
db2688f3a7 feat: rename variable 2024-12-28 11:28:43 -03:00
Zamitto
4e282921ef chore: dont remove installation dirs in custom install script
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
2024-12-27 17:42:12 -03:00
Zamitto
b22b998c29 Merge branch 'main' into feat/game-card-animation 2024-12-27 15:36:29 -03:00
Davi Souto
423693040b feat: added debounce to the launch options input and removed unnecessary fragment in the clear button 2024-12-27 00:53:13 -03:00
Davi Souto
c098d8ffcf fix: added detached to the spawn to fix the game closing with the launcher 2024-12-26 22:17:22 -03:00
Davi Souto
e1904b853e fix: security and persistence adjustments 2024-12-26 21:45:18 -03:00
Davi Souto
520eb91a55 feat: add custom launch options to game 2024-12-26 20:56:25 -03:00
Zamitto
16eaf4261a feat: animation and number format 2024-12-26 20:38:41 -03:00
Zamitto
ec289fe4c7 feat: animation 2024-12-26 15:07:05 -03:00
Zamitto
f5d5aa39dc feat: game card animation 2024-12-25 20:28:56 -03:00
24 changed files with 481 additions and 210 deletions

View File

@@ -1,7 +1,5 @@
!macro customUnInstall
${ifNot} ${isUpdated}
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
RMDir /r "$APPDATA\hydra"
RMDir /r "$LOCALAPPDATA\hydralauncher-updater"
${endIf}
!macroend

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.1.2",
"version": "3.1.3",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",

View File

@@ -46,10 +46,20 @@
"checking_files": "Проверка на {{title}} файловете… ({{percentage}} готово)"
},
"catalogue": {
"next_page": "Следваща страница",
"previous_page": "Предишна страница"
"search": "Филтър…",
"developers": "Разработчици",
"genres": "Жанрове",
"tags": "Тагове",
"publishers": "Издатели",
"download_sources": "Източници за изтегляне",
"result_count": "{{resultCount}} резултати",
"filter_count": "{{filterCount}} налични",
"clear_filters": "Изчисти {{filterCount}} избрани"
},
"game_details": {
"launch_options": "Опции за стартиране",
"launch_options_description": "Напредналите потребители могат да въведат модификации на своите опции за стартиране",
"launch_options_placeholder": "Няма зададен параметър",
"open_download_options": "Варианти за изтегляне",
"download_options_zero": "Няма варианти за изтегляне",
"download_options_one": "{{count}} варианти за изтегляне",
@@ -177,6 +187,10 @@
"loading": "Зареждане…"
},
"downloads": {
"seeding": "Сийдване",
"stop_seeding": "Спри сийдването",
"resume_seeding": "Продължи сийдването",
"options": "Управление",
"resume": "Продължи",
"pause": "Пауза",
"eta": "Conclusion {{eta}}",
@@ -202,6 +216,8 @@
"checking_files": "Проверка на файлове…"
},
"settings": {
"seed_after_download_complete": "Сийд след завършване на изтеглянето",
"show_hidden_achievement_description": "Показвай описанието на скритите постижения преди отключването им",
"downloads_path": "Инсталационен път",
"change": "Актуализиране",
"notifications": "Известия",
@@ -210,7 +226,7 @@
"real_debrid_api_token_label": "Real-Debrid API токен",
"quit_app_instead_hiding": "Не скривайте Hydra при затваряне",
"launch_with_system": "Стартиране на Hydra при стартиране на системата",
"general": "Общ",
"general": "Общи",
"behavior": "Поведение",
"download_sources": "Източници за изтегляне",
"language": "Език",
@@ -288,6 +304,16 @@
"toggle_password_visibility": "Превключване на видимостта на паролата"
},
"user_profile": {
"stats": "Статистики",
"achievements": "Постижения",
"games": "Игри",
"top_percentile": "Топ {{percentile}}%",
"ranking_updated_weekly": "Класацията се актуализира седмично",
"playing": "Играе {{game}}",
"achievements_unlocked": "Отключени постижения",
"earned_points": "Спечелени точки",
"show_achievements_on_profile": "Показвай своите постижения в профила",
"show_points_on_profile": "Показвай спечелените точки в профила",
"amount_hours": "{{amount}} часове",
"amount_minutes": "{{amount}} минути",
"last_time_played": "Последно играно {{period}}",
@@ -359,6 +385,11 @@
"background_image_updated": "Обновено фоново изображение"
},
"achievement": {
"hidden_achievement_tooltip": "Това е скрито постижение",
"achievement_earn_points": "Спечели {{points}} точки с това постижение",
"earned_points": "Спечелени точки:",
"available_points": "Налични точки:",
"how_to_earn_achievements_points": "Как да спечелиш точки за постижения?",
"achievement_unlocked": "Постижението е отключено",
"user_achievements": "Постиженията на {{displayName}} ",
"your_achievements": "Вашите Постижения",
@@ -369,6 +400,9 @@
"achievements_unlocked_for_game": "Отключени {{achievementCount}} нови постижения за {{gameTitle}}"
},
"hydra_cloud": {
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "Открихте функция на Hydra Cloud!",
"learn_more": "Научете повече",
"subscription_tour_title": "Hydra Cloud Абонамент",
"subscribe_now": "Абонирай се сега",
"cloud_saving": "Запазване в облака",

View File

@@ -167,6 +167,9 @@
"loading_save_preview": "Searching for save games…",
"wine_prefix": "Wine Prefix",
"wine_prefix_description": "The Wine prefix used to run this game",
"launch_options": "Launch Options",
"launch_options_description": "Advanced users may choose to enter modifications to their launch options",
"launch_options_placeholder": "No parameter specified",
"no_download_option_info": "No information available",
"backup_deletion_failed": "Failed to delete backup",
"max_number_of_artifacts_reached": "Maximum number of backups reached for this game",

View File

@@ -155,6 +155,9 @@
"loading_save_preview": "Buscando por arquivos de salvamento…",
"wine_prefix": "Prefixo Wine",
"wine_prefix_description": "O prefixo Wine que foi utilizado para instalar o jogo",
"launch_options": "Opções de Inicialização",
"launch_options_description": "Usuários avançados podem adicionar opções de inicialização no jogo",
"launch_options_placeholder": "Nenhum parâmetro informado",
"no_download_option_info": "Sem informações disponíveis",
"backup_deletion_failed": "Falha ao apagar backup",
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo",

View File

@@ -37,6 +37,9 @@ export class Game {
@Column("text", { nullable: true })
executablePath: string | null;
@Column("text", { nullable: true })
launchOptions: string | null;
@Column("text", { nullable: true })
winePrefixPath: string | null;

View File

@@ -0,0 +1,9 @@
export const parseLaunchOptions = (params: string | null): string[] => {
if (params == null || params == "") {
return [];
}
const paramsSplit = params.split(" ");
return paramsSplit;
};

View File

@@ -22,6 +22,7 @@ import "./library/open-game-executable-path";
import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/update-launch-options";
import "./library/verify-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";

View File

@@ -2,18 +2,31 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { spawn } from "child_process";
import { parseExecutablePath } from "../helpers/parse-executable-path";
import { parseLaunchOptions } from "../helpers/parse-launch-options";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number,
executablePath: string
executablePath: string,
launchOptions: string | null
) => {
const parsedPath = parseExecutablePath(executablePath);
const parsedParams = parseLaunchOptions(launchOptions);
await gameRepository.update({ id: gameId }, { executablePath: parsedPath });
await gameRepository.update(
{ id: gameId },
{ executablePath: parsedPath, launchOptions }
);
shell.openPath(parsedPath);
if (process.platform === "linux" || process.platform === "darwin") {
shell.openPath(parsedPath);
}
if (process.platform === "win32") {
spawn(parsedPath, parsedParams, { shell: false, detached: true });
}
};
registerEvent("openGame", openGame);

View File

@@ -0,0 +1,19 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const updateLaunchOptions = async (
_event: Electron.IpcMainInvokeEvent,
id: number,
launchOptions: string | null
) => {
return gameRepository.update(
{
id,
},
{
launchOptions: launchOptions?.trim() != "" ? launchOptions : null,
}
);
};
registerEvent("updateLaunchOptions", updateLaunchOptions);

View File

@@ -16,6 +16,7 @@ import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disab
import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum";
import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download";
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
import { AddLaunchOptionsColumnToGame } from "./migrations/20241226044022_add_launch_options_column_to_game";
export type HydraMigration = Knex.Migration & { name: string };
@@ -37,6 +38,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
AddShouldSeedColumn,
AddSeedAfterDownloadColumn,
AddHiddenAchievementDescriptionColumn,
AddLaunchOptionsColumnToGame,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddLaunchOptionsColumnToGame: HydraMigration = {
name: "AddLaunchOptionsColumnToGame",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.string("launchOptions").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("launchOptions");
});
},
};

View File

@@ -244,7 +244,7 @@ export class DownloadManager {
private static async getDownloadPayload(game: Game) {
switch (game.downloader) {
case Downloader.Gofile: {
const id = game!.uri!.split("/").pop();
const id = game.uri!.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
@@ -258,7 +258,7 @@ export class DownloadManager {
};
}
case Downloader.PixelDrain: {
const id = game!.uri!.split("/").pop();
const id = game.uri!.split("/").pop();
return {
action: "start",

View File

@@ -277,14 +277,9 @@ export class WindowManager {
if (process.platform !== "darwin") {
await updateSystemTray();
tray.addListener("click", () => {
tray.addListener("double-click", () => {
if (this.mainWindow) {
if (
WindowManager.mainWindow?.isMinimized() ||
!WindowManager.mainWindow?.isVisible()
) {
WindowManager.mainWindow?.show();
}
this.mainWindow.show();
} else {
this.createMainWindow();
}

View File

@@ -104,6 +104,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("createGameShortcut", id),
updateExecutablePath: (id: number, executablePath: string | null) =>
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
updateLaunchOptions: (id: number, launchOptions: string | null) =>
ipcRenderer.invoke("updateLaunchOptions", id, launchOptions),
selectGameWinePrefix: (id: number, winePrefixPath: string | null) =>
ipcRenderer.invoke("selectGameWinePrefix", id, winePrefixPath),
verifyExecutablePathInUse: (executablePath: string) =>
@@ -115,8 +117,11 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("openGameInstallerPath", gameId),
openGameExecutablePath: (gameId: number) =>
ipcRenderer.invoke("openGameExecutablePath", gameId),
openGame: (gameId: number, executablePath: string) =>
ipcRenderer.invoke("openGame", gameId, executablePath),
openGame: (
gameId: number,
executablePath: string,
launchOptions: string | null
) => ipcRenderer.invoke("openGame", gameId, executablePath, launchOptions),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
removeGameFromLibrary: (gameId: number) =>
ipcRenderer.invoke("removeGameFromLibrary", gameId),

View File

@@ -154,7 +154,11 @@ export function Sidebar() {
if (event.detail === 2) {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
window.electron.openGame(
game.id,
game.executablePath,
game.launchOptions
);
} else {
showWarningToast(t("game_has_no_executable"));
}

View File

@@ -93,6 +93,10 @@ declare global {
id: number,
executablePath: string | null
) => Promise<void>;
updateLaunchOptions: (
id: number,
launchOptions: string | null
) => Promise<void>;
selectGameWinePrefix: (
id: number,
winePrefixPath: string | null
@@ -102,7 +106,11 @@ declare global {
openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
openGameExecutablePath: (gameId: number) => Promise<void>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
openGame: (
gameId: number,
executablePath: string,
launchOptions: string | null
) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>;
removeGame: (gameId: number) => Promise<void>;

View File

@@ -2,14 +2,17 @@ import type { GameShop } from "@types";
import Color from "color";
export const formatDownloadProgress = (progress?: number) => {
export const formatDownloadProgress = (
progress?: number,
fractionDigits?: number
) => {
if (!progress) return "0%";
const progressPercentage = progress * 100;
if (Number(progressPercentage.toFixed(2)) % 1 === 0)
if (Number(progressPercentage.toFixed(fractionDigits ?? 2)) % 1 === 0)
return `${Math.floor(progressPercentage)}%`;
return `${progressPercentage.toFixed(2)}%`;
return `${progressPercentage.toFixed(fractionDigits ?? 2)}%`;
};
export const getSteamLanguage = (language: string) => {

View File

@@ -55,13 +55,21 @@ export function HeroPanelActions() {
const openGame = async () => {
if (game) {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
window.electron.openGame(
game.id,
game.executablePath,
game.launchOptions
);
return;
}
const gameExecutablePath = await selectGameExecutable();
if (gameExecutablePath)
window.electron.openGame(game.id, gameExecutablePath);
window.electron.openGame(
game.id,
gameExecutablePath,
game.launchOptions
);
}
};

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from "react";
import { useContext, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
@@ -8,6 +8,7 @@ import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import { debounce } from "lodash-es";
export interface GameOptionsModalProps {
visible: boolean;
@@ -29,6 +30,7 @@ export function GameOptionsModal({
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
const {
removeGameInstaller,
@@ -44,6 +46,13 @@ export function GameOptionsModal({
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;
const debounceUpdateLaunchOptions = useRef(
debounce(async (value: string) => {
await window.electron.updateLaunchOptions(game.id, value);
updateGame();
}, 1000)
).current;
const handleRemoveGameFromLibrary = async () => {
if (isGameDownloading) {
await cancelDownload(game.id);
@@ -116,9 +125,25 @@ export function GameOptionsModal({
updateGame();
};
const handleChangeLaunchOptions = async (event) => {
const value = event.target.value;
setLaunchOptions(value);
debounceUpdateLaunchOptions(value);
};
const handleClearLaunchOptions = async () => {
setLaunchOptions("");
window.electron.updateLaunchOptions(game.id, null).then(updateGame);
};
const shouldShowWinePrefixConfiguration =
window.electron.platform === "linux";
const shouldShowLaunchOptionsConfiguration =
window.electron.platform === "win32";
return (
<>
<DeleteGameModal
@@ -226,6 +251,28 @@ export function GameOptionsModal({
</div>
)}
{shouldShowLaunchOptionsConfiguration && (
<div className={styles.gameOptionHeader}>
<h2>{t("launch_options")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("launch_options_description")}
</h4>
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
)}
<div className={styles.gameOptionHeader}>
<h2>{t("downloads_secion_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>

View File

@@ -228,3 +228,11 @@ export const link = style({
cursor: "pointer",
},
});
export const gameCardStats = style({
width: "100%",
height: "100%",
transition: "transform 0.5s ease-in-out",
flexShrink: "0",
flexGrow: "0",
});

View File

@@ -1,31 +1,27 @@
import { userProfileContext } from "@renderer/context";
import { useCallback, useContext, useEffect, useMemo } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { ClockIcon, TelescopeIcon, TrophyIcon } from "@primer/octicons-react";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import type { UserGame } from "@types";
import {
buildGameAchievementPath,
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { UserStatsBox } from "./user-stats-box";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { UserLibraryGameCard } from "./user-library-game-card";
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext);
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const statsAnimation = useRef(-1);
const dispatch = useAppDispatch();
@@ -39,6 +35,35 @@ export function ProfileContent() {
}
}, [userProfile, dispatch]);
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
const handleOnMouseLeaveGameCard = () => {
setIsAnimationRunning(true);
};
useEffect(() => {
let zero = performance.now();
if (!isAnimationRunning) return;
statsAnimation.current = requestAnimationFrame(
function animateGameStats(time) {
if (time - zero <= GAME_STATS_ANIMATION_DURATION_IN_MS) {
statsAnimation.current = requestAnimationFrame(animateGameStats);
} else {
setStatsIndex((index) => index + 1);
zero = performance.now();
statsAnimation.current = requestAnimationFrame(animateGameStats);
}
}
);
return () => {
cancelAnimationFrame(statsAnimation.current);
};
}, [setStatsIndex, isAnimationRunning]);
const { numberFormatter } = useFormat();
const navigate = useNavigate();
@@ -47,42 +72,6 @@ export function ProfileContent() {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
const buildUserGameDetailsPath = useCallback(
(game: UserGame) => {
if (!userProfile?.hasActiveSubscription || game.achievementCount === 0) {
return buildGameDetailsPath({
...game,
objectId: game.objectId,
});
}
const userParams = userProfile
? {
userId: userProfile.id,
}
: undefined;
return buildGameAchievementPath({ ...game }, userParams);
},
[userProfile]
);
const formatPlayTime = useCallback(
(playTimeInSeconds = 0) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
const content = useMemo(() => {
if (!userProfile) return null;
@@ -129,137 +118,13 @@ export function ProfileContent() {
<ul className={styles.gamesGrid}>
{userProfile?.libraryGames?.map((game) => (
<li
<UserLibraryGameCard
game={game}
key={game.objectId}
style={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
}}
title={game.title}
className={styles.game}
>
<button
type="button"
style={{
cursor: "pointer",
}}
className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div
style={{
position: "absolute",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "space-between",
height: "100%",
width: "100%",
background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.75) 25%, transparent 100%)",
padding: 8,
}}
>
<small
style={{
backgroundColor: vars.color.background,
color: vars.color.muted,
border: `solid 1px ${vars.color.border}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px",
}}
>
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
{userProfile.hasActiveSubscription &&
game.achievementCount > 0 && (
<div
style={{
color: "#fff",
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
{game.achievementsPointsEarnedSum > 0 && (
<div
style={{
display: "flex",
justifyContent: "start",
gap: 8,
marginBottom: 4,
color: vars.color.muted,
}}
>
<HydraIcon width={16} height={16} />
{numberFormatter.format(
game.achievementsPointsEarnedSum
)}
</div>
)}
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
<span>
{formatDownloadProgress(
game.unlockedAchievementCount /
game.achievementCount
)}
</span>
</div>
<progress
max={1}
value={
game.unlockedAchievementCount /
game.achievementCount
}
className={styles.achievementsProgressBar}
/>
</div>
)}
</div>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{
objectFit: "cover",
borderRadius: 4,
width: "100%",
height: "100%",
minWidth: "100%",
minHeight: "100%",
}}
/>
</button>
</li>
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
))}
</ul>
</>
@@ -271,7 +136,6 @@ export function ProfileContent() {
<UserStatsBox />
<RecentGamesBox />
<FriendsBox />
<ReportProfile />
</div>
)}
@@ -284,9 +148,8 @@ export function ProfileContent() {
userStats,
numberFormatter,
t,
buildUserGameDetailsPath,
formatPlayTime,
navigate,
statsIndex,
]);
return (

View File

@@ -0,0 +1,227 @@
import { UserGame } from "@types";
import * as styles from "./profile-content.css";
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 {
buildGameAchievementPath,
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { vars } from "@renderer/theme.css";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { useTranslation } from "react-i18next";
import { steamUrlBuilder } from "@shared";
interface UserLibraryGameCardProps {
game: UserGame;
statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function UserLibraryGameCard({
game,
statIndex,
onMouseEnter,
onMouseLeave,
}: UserLibraryGameCardProps) {
const { userProfile } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const getStatsItemCount = useCallback(() => {
let statsCount = 1;
if (game.achievementsPointsEarnedSum > 0) statsCount++;
return statsCount;
}, [game]);
const buildUserGameDetailsPath = useCallback(
(game: UserGame) => {
if (!userProfile?.hasActiveSubscription || game.achievementCount === 0) {
return buildGameDetailsPath({
...game,
objectId: game.objectId,
});
}
const userParams = userProfile
? {
userId: userProfile.id,
}
: undefined;
return buildGameAchievementPath({ ...game }, userParams);
},
[userProfile]
);
const formatAchievementPoints = (number: number) => {
if (number < 100_000) return numberFormatter.format(number);
if (number < 1_000_000) return `${(number / 1000).toFixed(1)}K`;
return `${(number / 1_000_000).toFixed(1)}M`;
};
const formatPlayTime = useCallback(
(playTimeInSeconds = 0) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
return (
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
}}
title={game.title}
className={styles.game}
>
<button
type="button"
style={{
cursor: "pointer",
}}
className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div
style={{
position: "absolute",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "space-between",
height: "100%",
width: "100%",
background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.70) 20%, transparent 100%)",
padding: 8,
}}
>
<small
style={{
backgroundColor: vars.color.background,
color: vars.color.muted,
border: `solid 1px ${vars.color.border}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px",
}}
>
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div
style={{
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
overflow: "hidden",
height: 18,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
<div
className={styles.gameCardStats}
style={{
display: "flex",
alignItems: "center",
gap: 8,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} / {game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className={styles.gameCardStats}
style={{
display: "flex",
gap: 5,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
alignItems: "center",
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className={styles.achievementsProgressBar}
/>
</div>
)}
</div>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{
objectFit: "cover",
borderRadius: 4,
width: "100%",
height: "100%",
minWidth: "100%",
minHeight: "100%",
}}
/>
</button>
</li>
);
}

View File

@@ -115,6 +115,7 @@ export interface Game {
downloader: Downloader;
winePrefixPath: string | null;
executablePath: string | null;
launchOptions: string | null;
lastTimePlayed: Date | null;
uri: string | null;
fileSize: number;