mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
20 Commits
feature/sh
...
v3.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2ac1eb93 | ||
|
|
726d99a5c7 | ||
|
|
402e5df9ac | ||
|
|
0ee55b7fd5 | ||
|
|
8286390d9f | ||
|
|
6e00fb8e13 | ||
|
|
9f5d8cadda | ||
|
|
9060d435cf | ||
|
|
d8e7fca224 | ||
|
|
3bef2633fd | ||
|
|
db2688f3a7 | ||
|
|
4e282921ef | ||
|
|
b22b998c29 | ||
|
|
423693040b | ||
|
|
c098d8ffcf | ||
|
|
e1904b853e | ||
|
|
520eb91a55 | ||
|
|
16eaf4261a | ||
|
|
ec289fe4c7 | ||
|
|
f5d5aa39dc |
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.1.2",
|
||||
"version": "3.1.3",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
|
||||
@@ -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": "Запазване в облака",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
9
src/main/events/helpers/parse-launch-options.ts
Normal file
9
src/main/events/helpers/parse-launch-options.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const parseLaunchOptions = (params: string | null): string[] => {
|
||||
if (params == null || params == "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paramsSplit = params.split(" ");
|
||||
|
||||
return paramsSplit;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
19
src/main/events/library/update-launch-options.ts
Normal file
19
src/main/events/library/update-launch-options.ts
Normal 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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
10
src/renderer/src/declaration.d.ts
vendored
10
src/renderer/src/declaration.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user