mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-02 16:23:48 +03:00
feat: adding aria2
This commit is contained in:
parent
a89e6760da
commit
4941709296
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@ -22,17 +22,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
- name: Install Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Build with cx_Freeze
|
|
||||||
run: python torrent-client/setup.py build
|
|
||||||
|
|
||||||
- name: Build Linux
|
- name: Build Linux
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: yarn build:linux
|
run: yarn build:linux
|
||||||
|
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@ -24,17 +24,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
- name: Install Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Build with cx_Freeze
|
|
||||||
run: python torrent-client/setup.py build
|
|
||||||
|
|
||||||
- name: Build Linux
|
- name: Build Linux
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: yarn build:linux
|
run: yarn build:linux
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,6 @@
|
|||||||
.vscode
|
.vscode
|
||||||
node_modules
|
node_modules
|
||||||
hydra-download-manager
|
aria2*
|
||||||
fastlist.exe
|
fastlist.exe
|
||||||
__pycache__
|
__pycache__
|
||||||
dist
|
dist
|
||||||
|
@ -3,7 +3,6 @@ productName: Hydra
|
|||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
- hydra-download-manager
|
|
||||||
- hydra.db
|
- hydra.db
|
||||||
- fastlist.exe
|
- fastlist.exe
|
||||||
- seeds
|
- seeds
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
"@vanilla-extract/css": "^1.14.2",
|
"@vanilla-extract/css": "^1.14.2",
|
||||||
"@vanilla-extract/recipes": "^0.5.2",
|
"@vanilla-extract/recipes": "^0.5.2",
|
||||||
|
"aria2": "^4.1.2",
|
||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"better-sqlite3": "^9.5.0",
|
"better-sqlite3": "^9.5.0",
|
||||||
|
80
src/main/declaration.d.ts
vendored
Normal file
80
src/main/declaration.d.ts
vendored
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
declare module "aria2" {
|
||||||
|
export type Aria2Status =
|
||||||
|
| "active"
|
||||||
|
| "waiting"
|
||||||
|
| "paused"
|
||||||
|
| "error"
|
||||||
|
| "complete"
|
||||||
|
| "removed";
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
gid: string;
|
||||||
|
status: Aria2Status;
|
||||||
|
totalLength: string;
|
||||||
|
completedLength: string;
|
||||||
|
uploadLength: string;
|
||||||
|
bitfield: string;
|
||||||
|
downloadSpeed: string;
|
||||||
|
uploadSpeed: string;
|
||||||
|
infoHash?: string;
|
||||||
|
numSeeders?: string;
|
||||||
|
seeder?: boolean;
|
||||||
|
pieceLength: string;
|
||||||
|
numPieces: string;
|
||||||
|
connections: string;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
followedBy?: string[];
|
||||||
|
following: string;
|
||||||
|
belongsTo: string;
|
||||||
|
dir: string;
|
||||||
|
files: {
|
||||||
|
path: string;
|
||||||
|
length: string;
|
||||||
|
completedLength: string;
|
||||||
|
selected: string;
|
||||||
|
}[];
|
||||||
|
bittorrent?: {
|
||||||
|
announceList: string[][];
|
||||||
|
comment: string;
|
||||||
|
creationDate: string;
|
||||||
|
mode: "single" | "multi";
|
||||||
|
info: {
|
||||||
|
name: string;
|
||||||
|
verifiedLength: string;
|
||||||
|
verifyIntegrityPending: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Aria2 {
|
||||||
|
constructor(options: any);
|
||||||
|
open: () => Promise<void>;
|
||||||
|
call(
|
||||||
|
method: "addUri",
|
||||||
|
uris: string[],
|
||||||
|
options: { dir: string }
|
||||||
|
): Promise<string>;
|
||||||
|
call(
|
||||||
|
method: "tellStatus",
|
||||||
|
gid: string,
|
||||||
|
keys?: string[]
|
||||||
|
): Promise<StatusResponse>;
|
||||||
|
call(method: "pause", gid: string): Promise<string>;
|
||||||
|
call(method: "forcePause", gid: string): Promise<string>;
|
||||||
|
call(method: "unpause", gid: string): Promise<string>;
|
||||||
|
call(method: "remove", gid: string): Promise<string>;
|
||||||
|
call(method: "forceRemove", gid: string): Promise<string>;
|
||||||
|
call(method: "pauseAll"): Promise<string>;
|
||||||
|
call(method: "forcePauseAll"): Promise<string>;
|
||||||
|
listNotifications: () => [
|
||||||
|
"onDownloadStart",
|
||||||
|
"onDownloadPause",
|
||||||
|
"onDownloadStop",
|
||||||
|
"onDownloadComplete",
|
||||||
|
"onDownloadError",
|
||||||
|
"onBtDownloadComplete",
|
||||||
|
];
|
||||||
|
on: (event: string, callback: (params: any) => void) => void;
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,8 @@ import {
|
|||||||
import { Repack } from "./repack.entity";
|
import { Repack } from "./repack.entity";
|
||||||
|
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { Downloader, GameStatus } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
|
import type { Aria2Status } from "aria2";
|
||||||
|
|
||||||
@Entity("game")
|
@Entity("game")
|
||||||
export class Game {
|
export class Game {
|
||||||
@ -42,7 +43,7 @@ export class Game {
|
|||||||
shop: GameShop;
|
shop: GameShop;
|
||||||
|
|
||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
status: GameStatus | null;
|
status: Aria2Status | null;
|
||||||
|
|
||||||
@Column("int", { default: Downloader.Torrent })
|
@Column("int", { default: Downloader.Torrent })
|
||||||
downloader: Downloader;
|
downloader: Downloader;
|
||||||
@ -53,9 +54,6 @@ export class Game {
|
|||||||
@Column("float", { default: 0 })
|
@Column("float", { default: 0 })
|
||||||
progress: number;
|
progress: number;
|
||||||
|
|
||||||
@Column("float", { default: 0 })
|
|
||||||
fileVerificationProgress: number;
|
|
||||||
|
|
||||||
@Column("int", { default: 0 })
|
@Column("int", { default: 0 })
|
||||||
bytesDownloaded: number;
|
bytesDownloaded: number;
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||||
@ -15,7 +14,7 @@ const deleteGameFolder = async (
|
|||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
id: gameId,
|
id: gameId,
|
||||||
status: GameStatus.Cancelled,
|
status: "removed",
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -2,7 +2,6 @@ import { gameRepository } from "@main/repository";
|
|||||||
|
|
||||||
import { searchRepacks } from "../helpers/search-games";
|
import { searchRepacks } from "../helpers/search-games";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
import { sortBy } from "lodash-es";
|
import { sortBy } from "lodash-es";
|
||||||
|
|
||||||
const getLibrary = async () =>
|
const getLibrary = async () =>
|
||||||
@ -24,7 +23,7 @@ const getLibrary = async () =>
|
|||||||
...game,
|
...game,
|
||||||
repacks: searchRepacks(game.title),
|
repacks: searchRepacks(game.title),
|
||||||
})),
|
})),
|
||||||
(game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
|
(game) => (game.status !== "removed" ? 0 : 1)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameRepository } from "../../repository";
|
import { gameRepository } from "../../repository";
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
|
|
||||||
const removeGame = async (
|
const removeGame = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -9,7 +8,7 @@ const removeGame = async (
|
|||||||
await gameRepository.update(
|
await gameRepository.update(
|
||||||
{
|
{
|
||||||
id: gameId,
|
id: gameId,
|
||||||
status: GameStatus.Cancelled,
|
status: "removed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: null,
|
status: null,
|
||||||
|
@ -1,53 +1,25 @@
|
|||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { WindowManager } from "@main/services";
|
|
||||||
|
|
||||||
import { In } from "typeorm";
|
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
|
|
||||||
const cancelGameDownload = async (
|
const cancelGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
gameId: number
|
gameId: number
|
||||||
) => {
|
) => {
|
||||||
const game = await gameRepository.findOne({
|
await DownloadManager.cancelDownload(gameId);
|
||||||
where: {
|
|
||||||
|
await gameRepository.update(
|
||||||
|
{
|
||||||
id: gameId,
|
id: gameId,
|
||||||
isDeleted: false,
|
|
||||||
status: In([
|
|
||||||
GameStatus.Downloading,
|
|
||||||
GameStatus.DownloadingMetadata,
|
|
||||||
GameStatus.CheckingFiles,
|
|
||||||
GameStatus.Paused,
|
|
||||||
GameStatus.Seeding,
|
|
||||||
GameStatus.Finished,
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
status: "removed",
|
||||||
if (!game) return;
|
bytesDownloaded: 0,
|
||||||
DownloadManager.cancelDownload();
|
progress: 0,
|
||||||
|
}
|
||||||
await gameRepository
|
);
|
||||||
.update(
|
|
||||||
{
|
|
||||||
id: game.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: GameStatus.Cancelled,
|
|
||||||
bytesDownloaded: 0,
|
|
||||||
progress: 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((result) => {
|
|
||||||
if (
|
|
||||||
game.status !== GameStatus.Paused &&
|
|
||||||
game.status !== GameStatus.Seeding
|
|
||||||
) {
|
|
||||||
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("cancelGameDownload", cancelGameDownload);
|
registerEvent("cancelGameDownload", cancelGameDownload);
|
||||||
|
@ -1,30 +1,13 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameRepository } from "../../repository";
|
import { gameRepository } from "../../repository";
|
||||||
import { In } from "typeorm";
|
import { DownloadManager } from "@main/services";
|
||||||
import { DownloadManager, WindowManager } from "@main/services";
|
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
|
|
||||||
const pauseGameDownload = async (
|
const pauseGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
gameId: number
|
gameId: number
|
||||||
) => {
|
) => {
|
||||||
DownloadManager.pauseDownload();
|
await DownloadManager.pauseDownload();
|
||||||
|
await gameRepository.update({ id: gameId }, { status: "paused" });
|
||||||
await gameRepository
|
|
||||||
.update(
|
|
||||||
{
|
|
||||||
id: gameId,
|
|
||||||
status: In([
|
|
||||||
GameStatus.Downloading,
|
|
||||||
GameStatus.DownloadingMetadata,
|
|
||||||
GameStatus.CheckingFiles,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ status: GameStatus.Paused }
|
|
||||||
)
|
|
||||||
.then((result) => {
|
|
||||||
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("pauseGameDownload", pauseGameDownload);
|
registerEvent("pauseGameDownload", pauseGameDownload);
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameRepository } from "../../repository";
|
import { gameRepository } from "../../repository";
|
||||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
|
||||||
import { In } from "typeorm";
|
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
|
|
||||||
const resumeGameDownload = async (
|
const resumeGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -18,31 +16,13 @@ const resumeGameDownload = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
DownloadManager.pauseDownload();
|
|
||||||
|
|
||||||
if (game.status === GameStatus.Paused) {
|
if (game.status === "paused") {
|
||||||
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
|
await DownloadManager.pauseDownload();
|
||||||
|
|
||||||
DownloadManager.resumeDownload(gameId);
|
await gameRepository.update({ status: "active" }, { status: "paused" });
|
||||||
|
|
||||||
await gameRepository.update(
|
await DownloadManager.resumeDownload(gameId);
|
||||||
{
|
|
||||||
status: In([
|
|
||||||
GameStatus.Downloading,
|
|
||||||
GameStatus.DownloadingMetadata,
|
|
||||||
GameStatus.CheckingFiles,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ status: GameStatus.Paused }
|
|
||||||
);
|
|
||||||
|
|
||||||
await gameRepository.update(
|
|
||||||
{ id: game.id },
|
|
||||||
{
|
|
||||||
status: GameStatus.Downloading,
|
|
||||||
downloadPath: downloadsPath,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -8,9 +8,8 @@ import { registerEvent } from "../register-event";
|
|||||||
|
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
||||||
import { In } from "typeorm";
|
|
||||||
import { DownloadManager } from "@main/services";
|
import { DownloadManager } from "@main/services";
|
||||||
import { Downloader, GameStatus } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import { stateManager } from "@main/state-manager";
|
import { stateManager } from "@main/state-manager";
|
||||||
|
|
||||||
const startGameDownload = async (
|
const startGameDownload = async (
|
||||||
@ -42,19 +41,9 @@ const startGameDownload = async (
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!repack || game?.status === GameStatus.Downloading) return;
|
if (!repack || game?.status === "active") return;
|
||||||
DownloadManager.pauseDownload();
|
|
||||||
|
|
||||||
await gameRepository.update(
|
await gameRepository.update({ status: "active" }, { status: "paused" });
|
||||||
{
|
|
||||||
status: In([
|
|
||||||
GameStatus.Downloading,
|
|
||||||
GameStatus.DownloadingMetadata,
|
|
||||||
GameStatus.CheckingFiles,
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ status: GameStatus.Paused }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (game) {
|
if (game) {
|
||||||
await gameRepository.update(
|
await gameRepository.update(
|
||||||
@ -62,17 +51,17 @@ const startGameDownload = async (
|
|||||||
id: game.id,
|
id: game.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: GameStatus.DownloadingMetadata,
|
status: "active",
|
||||||
downloadPath: downloadPath,
|
downloadPath,
|
||||||
downloader,
|
downloader,
|
||||||
repack: { id: repackId },
|
repack: { id: repackId },
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
DownloadManager.downloadGame(game.id);
|
await DownloadManager.startDownload(game.id);
|
||||||
|
|
||||||
game.status = GameStatus.DownloadingMetadata;
|
game.status = "active";
|
||||||
|
|
||||||
return game;
|
return game;
|
||||||
} else {
|
} else {
|
||||||
@ -91,7 +80,7 @@ const startGameDownload = async (
|
|||||||
objectID,
|
objectID,
|
||||||
downloader,
|
downloader,
|
||||||
shop: gameShop,
|
shop: gameShop,
|
||||||
status: GameStatus.Downloading,
|
status: "active",
|
||||||
downloadPath,
|
downloadPath,
|
||||||
repack: { id: repackId },
|
repack: { id: repackId },
|
||||||
})
|
})
|
||||||
@ -105,7 +94,7 @@ const startGameDownload = async (
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadManager.downloadGame(createdGame.id);
|
DownloadManager.startDownload(createdGame.id);
|
||||||
|
|
||||||
const { repack: _, ...rest } = createdGame;
|
const { repack: _, ...rest } = createdGame;
|
||||||
|
|
||||||
|
@ -13,17 +13,15 @@ import {
|
|||||||
repackRepository,
|
repackRepository,
|
||||||
userPreferencesRepository,
|
userPreferencesRepository,
|
||||||
} from "./repository";
|
} from "./repository";
|
||||||
import { TorrentDownloader } from "./services";
|
|
||||||
import { Repack, UserPreferences } from "./entity";
|
import { Repack, UserPreferences } from "./entity";
|
||||||
import { Notification } from "electron";
|
import { Notification } from "electron";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
import { In } from "typeorm";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { RealDebridClient } from "./services/real-debrid";
|
import { RealDebridClient } from "./services/real-debrid";
|
||||||
import { orderBy } from "lodash-es";
|
import { orderBy } from "lodash-es";
|
||||||
import { SteamGame } from "@types";
|
import { SteamGame } from "@types";
|
||||||
|
import { Not } from "typeorm";
|
||||||
|
|
||||||
startProcessWatcher();
|
startProcessWatcher();
|
||||||
|
|
||||||
@ -72,7 +70,7 @@ const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||||
const repacks = await repackRepository.find({
|
const repacks = repackRepository.find({
|
||||||
order: {
|
order: {
|
||||||
createdAt: "desc",
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
@ -82,7 +80,7 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||||||
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
|
fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
|
||||||
) as SteamGame[];
|
) as SteamGame[];
|
||||||
|
|
||||||
stateManager.setValue("repacks", repacks);
|
stateManager.setValue("repacks", await repacks);
|
||||||
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
|
stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
|
||||||
|
|
||||||
import("./events");
|
import("./events");
|
||||||
@ -90,22 +88,19 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||||||
if (userPreferences?.realDebridApiToken)
|
if (userPreferences?.realDebridApiToken)
|
||||||
await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||||
|
|
||||||
|
await DownloadManager.connect();
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
status: In([
|
status: "active",
|
||||||
GameStatus.Downloading,
|
progress: Not(1),
|
||||||
GameStatus.DownloadingMetadata,
|
|
||||||
GameStatus.CheckingFiles,
|
|
||||||
]),
|
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
relations: { repack: true },
|
relations: { repack: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await TorrentDownloader.startClient();
|
|
||||||
|
|
||||||
if (game) {
|
if (game) {
|
||||||
DownloadManager.resumeDownload(game.id);
|
DownloadManager.startDownload(game.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,13 +1,156 @@
|
|||||||
import { gameRepository } from "@main/repository";
|
import Aria2, { StatusResponse } from "aria2";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
import type { Game } from "@main/entity";
|
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { WindowManager } from "./window-manager";
|
||||||
|
import { RealDebridClient } from "./real-debrid";
|
||||||
|
import { Notification } from "electron";
|
||||||
|
import { t } from "i18next";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
|
import { DownloadProgress } from "@types";
|
||||||
import { writePipe } from "./fifo";
|
|
||||||
import { RealDebridDownloader } from "./downloaders";
|
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static gameDownloading: Game;
|
private static downloads = new Map<number, string>();
|
||||||
|
|
||||||
|
private static gid: string | null = null;
|
||||||
|
private static gameId: number | null = null;
|
||||||
|
|
||||||
|
private static aria2 = new Aria2({});
|
||||||
|
|
||||||
|
static async connect() {
|
||||||
|
const binary = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"aria2-1.37.0-win-64bit-build1",
|
||||||
|
"aria2c"
|
||||||
|
);
|
||||||
|
|
||||||
|
spawn(binary, ["--enable-rpc", "--rpc-listen-all"], { stdio: "inherit" });
|
||||||
|
|
||||||
|
await this.aria2.open();
|
||||||
|
this.attachListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getETA(status: StatusResponse) {
|
||||||
|
const remainingBytes =
|
||||||
|
Number(status.totalLength) - Number(status.completedLength);
|
||||||
|
const speed = Number(status.downloadSpeed);
|
||||||
|
|
||||||
|
if (remainingBytes >= 0 && speed > 0) {
|
||||||
|
return (remainingBytes / speed) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async publishNotification() {
|
||||||
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userPreferences?.downloadNotificationsEnabled && this.gameId) {
|
||||||
|
const game = await this.getGame(this.gameId);
|
||||||
|
|
||||||
|
new Notification({
|
||||||
|
title: t("download_complete", {
|
||||||
|
ns: "notifications",
|
||||||
|
lng: userPreferences.language,
|
||||||
|
}),
|
||||||
|
body: t("game_ready_to_install", {
|
||||||
|
ns: "notifications",
|
||||||
|
lng: userPreferences.language,
|
||||||
|
title: game?.title,
|
||||||
|
}),
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getFolderName(status: StatusResponse) {
|
||||||
|
if (status.bittorrent?.info) return status.bittorrent.info.name;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async attachListener() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (!this.gid || !this.gameId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await this.aria2.call("tellStatus", this.gid);
|
||||||
|
|
||||||
|
const downloadingMetadata =
|
||||||
|
status.bittorrent && !status.bittorrent?.info;
|
||||||
|
|
||||||
|
if (status.followedBy?.length) {
|
||||||
|
this.gid = status.followedBy[0];
|
||||||
|
this.downloads.set(this.gameId, this.gid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress =
|
||||||
|
Number(status.completedLength) / Number(status.totalLength);
|
||||||
|
|
||||||
|
await gameRepository.update(
|
||||||
|
{ id: this.gameId },
|
||||||
|
{
|
||||||
|
progress:
|
||||||
|
isNaN(progress) || downloadingMetadata ? undefined : progress,
|
||||||
|
bytesDownloaded: Number(status.completedLength),
|
||||||
|
fileSize: Number(status.totalLength),
|
||||||
|
status: status.status,
|
||||||
|
folderName: this.getFolderName(status),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: { id: this.gameId, isDeleted: false },
|
||||||
|
relations: { repack: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progress === 1 && game && !downloadingMetadata) {
|
||||||
|
await this.publishNotification();
|
||||||
|
/*
|
||||||
|
Only cancel bittorrent downloads to stop seeding
|
||||||
|
*/
|
||||||
|
if (status.bittorrent) {
|
||||||
|
await this.cancelDownload(game.id);
|
||||||
|
} else {
|
||||||
|
this.clearCurrentDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WindowManager.mainWindow && game) {
|
||||||
|
WindowManager.mainWindow.setProgressBar(
|
||||||
|
progress === 1 || downloadingMetadata ? -1 : progress,
|
||||||
|
{ mode: downloadingMetadata ? "indeterminate" : "normal" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
progress,
|
||||||
|
bytesDownloaded: Number(status.completedLength),
|
||||||
|
fileSize: Number(status.totalLength),
|
||||||
|
numPeers: Number(status.connections),
|
||||||
|
numSeeds: Number(status.numSeeders ?? 0),
|
||||||
|
downloadSpeed: Number(status.downloadSpeed),
|
||||||
|
timeRemaining: this.getETA(status),
|
||||||
|
downloadingMetadata: !!downloadingMetadata,
|
||||||
|
game,
|
||||||
|
} as DownloadProgress;
|
||||||
|
|
||||||
|
WindowManager.mainWindow.webContents.send(
|
||||||
|
"on-download-progress",
|
||||||
|
JSON.parse(JSON.stringify(payload))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async getGame(gameId: number) {
|
static async getGame(gameId: number) {
|
||||||
return gameRepository.findOne({
|
return gameRepository.findOne({
|
||||||
@ -18,59 +161,80 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async cancelDownload() {
|
private static clearCurrentDownload() {
|
||||||
if (
|
if (this.gameId) {
|
||||||
this.gameDownloading &&
|
this.downloads.delete(this.gameId);
|
||||||
this.gameDownloading.downloader === Downloader.Torrent
|
this.gid = null;
|
||||||
) {
|
this.gameId = null;
|
||||||
writePipe.write({ action: "cancel" });
|
}
|
||||||
} else {
|
}
|
||||||
RealDebridDownloader.destroy();
|
|
||||||
|
static async cancelDownload(gameId: number) {
|
||||||
|
const gid = this.downloads.get(gameId);
|
||||||
|
|
||||||
|
if (gid) {
|
||||||
|
await this.aria2.call("remove", gid);
|
||||||
|
|
||||||
|
if (this.gid === gid) {
|
||||||
|
this.clearCurrentDownload();
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
|
} else {
|
||||||
|
this.downloads.delete(gameId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async pauseDownload() {
|
static async pauseDownload() {
|
||||||
if (
|
if (this.gid) {
|
||||||
this.gameDownloading &&
|
await this.aria2.call("forcePause", this.gid);
|
||||||
this.gameDownloading.downloader === Downloader.Torrent
|
this.gid = null;
|
||||||
) {
|
this.gameId = null;
|
||||||
writePipe.write({ action: "pause" });
|
|
||||||
} else {
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
RealDebridDownloader.destroy();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resumeDownload(gameId: number) {
|
static async resumeDownload(gameId: number) {
|
||||||
const game = await this.getGame(gameId);
|
await this.aria2.call("forcePauseAll");
|
||||||
|
|
||||||
if (game!.downloader === Downloader.Torrent) {
|
if (this.downloads.has(gameId)) {
|
||||||
writePipe.write({
|
const gid = this.downloads.get(gameId)!;
|
||||||
action: "start",
|
await this.aria2.call("unpause", gid);
|
||||||
game_id: game!.id,
|
|
||||||
magnet: game!.repack.magnet,
|
this.gid = gid;
|
||||||
save_path: game!.downloadPath,
|
this.gameId = gameId;
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
RealDebridDownloader.startDownload(game!);
|
return this.startDownload(gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gameDownloading = game!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async downloadGame(gameId: number) {
|
static async startDownload(gameId: number) {
|
||||||
const game = await this.getGame(gameId);
|
await this.aria2.call("forcePauseAll");
|
||||||
|
|
||||||
if (game!.downloader === Downloader.Torrent) {
|
const game = await this.getGame(gameId)!;
|
||||||
writePipe.write({
|
|
||||||
action: "start",
|
if (game) {
|
||||||
game_id: game!.id,
|
const options = {
|
||||||
magnet: game!.repack.magnet,
|
dir: game.downloadPath!,
|
||||||
save_path: game!.downloadPath,
|
};
|
||||||
});
|
|
||||||
} else {
|
if (game.downloader === Downloader.RealDebrid) {
|
||||||
RealDebridDownloader.startDownload(game!);
|
const downloadUrl = decodeURIComponent(
|
||||||
|
await RealDebridClient.getDownloadUrl(game)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.gid = await this.aria2.call("addUri", [downloadUrl], options);
|
||||||
|
} else {
|
||||||
|
this.gid = await this.aria2.call(
|
||||||
|
"addUri",
|
||||||
|
[game.repack.magnet],
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.downloads.set(gameId, this.gid);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gameDownloading = game!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,85 +0,0 @@
|
|||||||
import { t } from "i18next";
|
|
||||||
import { Notification } from "electron";
|
|
||||||
|
|
||||||
import { Game } from "@main/entity";
|
|
||||||
|
|
||||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
|
|
||||||
import { WindowManager } from "../window-manager";
|
|
||||||
import type { TorrentUpdate } from "./torrent.downloader";
|
|
||||||
|
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
|
||||||
|
|
||||||
interface DownloadStatus {
|
|
||||||
numPeers?: number;
|
|
||||||
numSeeds?: number;
|
|
||||||
downloadSpeed?: number;
|
|
||||||
timeRemaining?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Downloader {
|
|
||||||
static getGameProgress(game: Game) {
|
|
||||||
if (game.status === GameStatus.CheckingFiles)
|
|
||||||
return game.fileVerificationProgress;
|
|
||||||
|
|
||||||
return game.progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async updateGameProgress(
|
|
||||||
gameId: number,
|
|
||||||
gameUpdate: QueryDeepPartialEntity<Game>,
|
|
||||||
downloadStatus: DownloadStatus
|
|
||||||
) {
|
|
||||||
await gameRepository.update({ id: gameId }, gameUpdate);
|
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
|
||||||
where: { id: gameId, isDeleted: false },
|
|
||||||
relations: { repack: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (game?.progress === 1) {
|
|
||||||
const userPreferences = await userPreferencesRepository.findOne({
|
|
||||||
where: { id: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userPreferences?.downloadNotificationsEnabled) {
|
|
||||||
new Notification({
|
|
||||||
title: t("download_complete", {
|
|
||||||
ns: "notifications",
|
|
||||||
lng: userPreferences.language,
|
|
||||||
}),
|
|
||||||
body: t("game_ready_to_install", {
|
|
||||||
ns: "notifications",
|
|
||||||
lng: userPreferences.language,
|
|
||||||
title: game?.title,
|
|
||||||
}),
|
|
||||||
}).show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WindowManager.mainWindow && game) {
|
|
||||||
const progress = this.getGameProgress(game);
|
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
|
||||||
|
|
||||||
WindowManager.mainWindow.webContents.send(
|
|
||||||
"on-download-progress",
|
|
||||||
JSON.parse(
|
|
||||||
JSON.stringify({
|
|
||||||
...({
|
|
||||||
progress: gameUpdate.progress,
|
|
||||||
bytesDownloaded: gameUpdate.bytesDownloaded,
|
|
||||||
fileSize: gameUpdate.fileSize,
|
|
||||||
gameId,
|
|
||||||
numPeers: downloadStatus.numPeers,
|
|
||||||
numSeeds: downloadStatus.numSeeds,
|
|
||||||
downloadSpeed: downloadStatus.downloadSpeed,
|
|
||||||
timeRemaining: downloadStatus.timeRemaining,
|
|
||||||
} as TorrentUpdate),
|
|
||||||
game,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,2 +0,0 @@
|
|||||||
export * from "./real-debrid.downloader";
|
|
||||||
export * from "./torrent.downloader";
|
|
@ -1,115 +0,0 @@
|
|||||||
import { Game } from "@main/entity";
|
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import EasyDL from "easydl";
|
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
// import { fullArchive } from "node-7z-archive";
|
|
||||||
|
|
||||||
import { Downloader } from "./downloader";
|
|
||||||
import { RealDebridClient } from "../real-debrid";
|
|
||||||
|
|
||||||
export class RealDebridDownloader extends Downloader {
|
|
||||||
private static download: EasyDL;
|
|
||||||
private static downloadSize = 0;
|
|
||||||
|
|
||||||
private static getEta(bytesDownloaded: number, speed: number) {
|
|
||||||
const remainingBytes = this.downloadSize - bytesDownloaded;
|
|
||||||
|
|
||||||
if (remainingBytes >= 0 && speed > 0) {
|
|
||||||
return (remainingBytes / speed) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static createFolderIfNotExists(path: string) {
|
|
||||||
if (!fs.existsSync(path)) {
|
|
||||||
fs.mkdirSync(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// private static async startDecompression(
|
|
||||||
// rarFile: string,
|
|
||||||
// dest: string,
|
|
||||||
// game: Game
|
|
||||||
// ) {
|
|
||||||
// await fullArchive(rarFile, dest);
|
|
||||||
|
|
||||||
// const updatePayload: QueryDeepPartialEntity<Game> = {
|
|
||||||
// status: GameStatus.Finished,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// await this.updateGameProgress(game.id, updatePayload, {});
|
|
||||||
// }
|
|
||||||
|
|
||||||
static destroy() {
|
|
||||||
if (this.download) {
|
|
||||||
this.download.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async startDownload(game: Game) {
|
|
||||||
if (this.download) this.download.destroy();
|
|
||||||
const downloadUrl = decodeURIComponent(
|
|
||||||
await RealDebridClient.getDownloadUrl(game)
|
|
||||||
);
|
|
||||||
|
|
||||||
const filename = path.basename(downloadUrl);
|
|
||||||
const folderName = path.basename(filename, path.extname(filename));
|
|
||||||
|
|
||||||
const downloadPath = path.join(game.downloadPath!, folderName);
|
|
||||||
this.createFolderIfNotExists(downloadPath);
|
|
||||||
|
|
||||||
this.download = new EasyDL(downloadUrl, path.join(downloadPath, filename));
|
|
||||||
|
|
||||||
const metadata = await this.download.metadata();
|
|
||||||
|
|
||||||
this.downloadSize = metadata.size;
|
|
||||||
|
|
||||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
|
||||||
status: GameStatus.Downloading,
|
|
||||||
fileSize: metadata.size,
|
|
||||||
folderName,
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadStatus = {
|
|
||||||
timeRemaining: Number.POSITIVE_INFINITY,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
|
|
||||||
|
|
||||||
this.download.on("progress", async ({ total }) => {
|
|
||||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
|
||||||
status: GameStatus.Downloading,
|
|
||||||
progress: Math.min(0.99, total.percentage / 100),
|
|
||||||
bytesDownloaded: total.bytes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadStatus = {
|
|
||||||
downloadSpeed: total.speed,
|
|
||||||
timeRemaining: this.getEta(total.bytes ?? 0, total.speed ?? 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateGameProgress(game.id, updatePayload, downloadStatus);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.download.on("end", async () => {
|
|
||||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
|
||||||
status: GameStatus.Finished,
|
|
||||||
progress: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateGameProgress(game.id, updatePayload, {
|
|
||||||
timeRemaining: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
/* This has to be improved */
|
|
||||||
// this.startDecompression(
|
|
||||||
// path.join(downloadPath, filename),
|
|
||||||
// downloadPath,
|
|
||||||
// game
|
|
||||||
// );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,156 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import cp from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import { app, dialog } from "electron";
|
|
||||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
|
||||||
|
|
||||||
import { Game } from "@main/entity";
|
|
||||||
import { GameStatus } from "@shared";
|
|
||||||
import { Downloader } from "./downloader";
|
|
||||||
import { readPipe, writePipe } from "../fifo";
|
|
||||||
|
|
||||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
|
||||||
darwin: "hydra-download-manager",
|
|
||||||
linux: "hydra-download-manager",
|
|
||||||
win32: "hydra-download-manager.exe",
|
|
||||||
};
|
|
||||||
|
|
||||||
enum TorrentState {
|
|
||||||
CheckingFiles = 1,
|
|
||||||
DownloadingMetadata = 2,
|
|
||||||
Downloading = 3,
|
|
||||||
Finished = 4,
|
|
||||||
Seeding = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TorrentUpdate {
|
|
||||||
gameId: number;
|
|
||||||
progress: number;
|
|
||||||
downloadSpeed: number;
|
|
||||||
timeRemaining: number;
|
|
||||||
numPeers: number;
|
|
||||||
numSeeds: number;
|
|
||||||
status: TorrentState;
|
|
||||||
folderName: string;
|
|
||||||
fileSize: number;
|
|
||||||
bytesDownloaded: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BITTORRENT_PORT = "5881";
|
|
||||||
|
|
||||||
export class TorrentDownloader extends Downloader {
|
|
||||||
private static messageLength = 1024 * 2;
|
|
||||||
|
|
||||||
public static async attachListener() {
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
const buffer = readPipe.socket?.read(this.messageLength);
|
|
||||||
|
|
||||||
if (buffer === null) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = Buffer.from(
|
|
||||||
buffer.slice(0, buffer.indexOf(0x00))
|
|
||||||
).toString("utf-8");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(message) as TorrentUpdate;
|
|
||||||
|
|
||||||
const updatePayload: QueryDeepPartialEntity<Game> = {
|
|
||||||
bytesDownloaded: payload.bytesDownloaded,
|
|
||||||
status: this.getTorrentStateName(payload.status),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (payload.status === TorrentState.CheckingFiles) {
|
|
||||||
updatePayload.fileVerificationProgress = payload.progress;
|
|
||||||
} else {
|
|
||||||
if (payload.folderName) {
|
|
||||||
updatePayload.folderName = payload.folderName;
|
|
||||||
updatePayload.fileSize = payload.fileSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
[TorrentState.Downloading, TorrentState.Seeding].includes(
|
|
||||||
payload.status
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
updatePayload.progress = payload.progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateGameProgress(payload.gameId, updatePayload, {
|
|
||||||
numPeers: payload.numPeers,
|
|
||||||
numSeeds: payload.numSeeds,
|
|
||||||
downloadSpeed: payload.downloadSpeed,
|
|
||||||
timeRemaining: payload.timeRemaining,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static startClient() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const commonArgs = [
|
|
||||||
BITTORRENT_PORT,
|
|
||||||
writePipe.socketPath,
|
|
||||||
readPipe.socketPath,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (app.isPackaged) {
|
|
||||||
const binaryName = binaryNameByPlatform[process.platform]!;
|
|
||||||
const binaryPath = path.join(
|
|
||||||
process.resourcesPath,
|
|
||||||
"hydra-download-manager",
|
|
||||||
binaryName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
|
||||||
dialog.showErrorBox(
|
|
||||||
"Fatal",
|
|
||||||
"Hydra download manager binary not found. Please check if it has been removed by Windows Defender."
|
|
||||||
);
|
|
||||||
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
cp.spawn(binaryPath, commonArgs, {
|
|
||||||
stdio: "inherit",
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const scriptPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"torrent-client",
|
|
||||||
"main.py"
|
|
||||||
);
|
|
||||||
|
|
||||||
cp.spawn("python3", [scriptPath, ...commonArgs], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(
|
|
||||||
async () => {
|
|
||||||
this.attachListener();
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getTorrentStateName(state: TorrentState) {
|
|
||||||
if (state === TorrentState.CheckingFiles) return GameStatus.CheckingFiles;
|
|
||||||
if (state === TorrentState.Downloading) return GameStatus.Downloading;
|
|
||||||
if (state === TorrentState.DownloadingMetadata)
|
|
||||||
return GameStatus.DownloadingMetadata;
|
|
||||||
if (state === TorrentState.Finished) return GameStatus.Finished;
|
|
||||||
if (state === TorrentState.Seeding) return GameStatus.Seeding;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import net from "node:net";
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
import os from "node:os";
|
|
||||||
|
|
||||||
export class FIFO {
|
|
||||||
public socket: null | net.Socket = null;
|
|
||||||
public socketPath = this.generateSocketFilename();
|
|
||||||
|
|
||||||
private generateSocketFilename() {
|
|
||||||
const hash = crypto.randomBytes(16).toString("hex");
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return "\\\\.\\pipe\\" + hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.join(os.tmpdir(), hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
public write(data: any) {
|
|
||||||
if (!this.socket) return;
|
|
||||||
this.socket.write(Buffer.from(JSON.stringify(data)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public createPipe() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = net.createServer((socket) => {
|
|
||||||
this.socket = socket;
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(this.socketPath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const writePipe = new FIFO();
|
|
||||||
export const readPipe = new FIFO();
|
|
@ -5,8 +5,6 @@ export * from "./steam-250";
|
|||||||
export * from "./steam-grid";
|
export * from "./steam-grid";
|
||||||
export * from "./update-resolver";
|
export * from "./update-resolver";
|
||||||
export * from "./window-manager";
|
export * from "./window-manager";
|
||||||
export * from "./fifo";
|
|
||||||
export * from "./downloaders";
|
|
||||||
export * from "./download-manager";
|
export * from "./download-manager";
|
||||||
export * from "./how-long-to-beat";
|
export * from "./how-long-to-beat";
|
||||||
export * from "./process-watcher";
|
export * from "./process-watcher";
|
||||||
|
@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer } from "electron";
|
|||||||
import type {
|
import type {
|
||||||
CatalogueCategory,
|
CatalogueCategory,
|
||||||
GameShop,
|
GameShop,
|
||||||
TorrentProgress,
|
DownloadProgress,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
|
|
||||||
@ -32,10 +32,10 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("pauseGameDownload", gameId),
|
ipcRenderer.invoke("pauseGameDownload", gameId),
|
||||||
resumeGameDownload: (gameId: number) =>
|
resumeGameDownload: (gameId: number) =>
|
||||||
ipcRenderer.invoke("resumeGameDownload", gameId),
|
ipcRenderer.invoke("resumeGameDownload", gameId),
|
||||||
onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
|
onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
value: TorrentProgress
|
value: DownloadProgress
|
||||||
) => cb(value);
|
) => cb(value);
|
||||||
ipcRenderer.on("on-download-progress", listener);
|
ipcRenderer.on("on-download-progress", listener);
|
||||||
return () => ipcRenderer.removeListener("on-download-progress", listener);
|
return () => ipcRenderer.removeListener("on-download-progress", listener);
|
||||||
|
@ -19,7 +19,6 @@ import {
|
|||||||
setUserPreferences,
|
setUserPreferences,
|
||||||
toggleDraggingDisabled,
|
toggleDraggingDisabled,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import { GameStatusHelper } from "@shared";
|
|
||||||
|
|
||||||
document.body.classList.add(themeClass);
|
document.body.classList.add(themeClass);
|
||||||
|
|
||||||
@ -54,7 +53,7 @@ export function App({ children }: AppProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.electron.onDownloadProgress(
|
const unsubscribe = window.electron.onDownloadProgress(
|
||||||
(downloadProgress) => {
|
(downloadProgress) => {
|
||||||
if (GameStatusHelper.isReady(downloadProgress.game.status)) {
|
if (downloadProgress.game.progress === 1) {
|
||||||
clearDownload();
|
clearDownload();
|
||||||
updateLibrary();
|
updateLibrary();
|
||||||
return;
|
return;
|
||||||
|
@ -43,5 +43,11 @@ export const backdrop = recipe({
|
|||||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
windows: {
|
||||||
|
true: {
|
||||||
|
// SPACING_UNIT * 3 + title bar spacing
|
||||||
|
paddingTop: `${SPACING_UNIT * 3 + 35}px`,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -7,6 +7,13 @@ export interface BackdropProps {
|
|||||||
|
|
||||||
export function Backdrop({ isClosing = false, children }: BackdropProps) {
|
export function Backdrop({ isClosing = false, children }: BackdropProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.backdrop({ closing: isClosing })}>{children}</div>
|
<div
|
||||||
|
className={styles.backdrop({
|
||||||
|
closing: isClosing,
|
||||||
|
windows: window.electron.platform === "win32",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -7,17 +7,16 @@ import { vars } from "../../theme.css";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { VERSION_CODENAME } from "@renderer/constants";
|
import { VERSION_CODENAME } from "@renderer/constants";
|
||||||
import { GameStatus, GameStatusHelper } from "@shared";
|
|
||||||
|
|
||||||
export function BottomPanel() {
|
export function BottomPanel() {
|
||||||
const { t } = useTranslation("bottom_panel");
|
const { t } = useTranslation("bottom_panel");
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { game, progress, downloadSpeed, eta } = useDownload();
|
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||||
|
|
||||||
const isGameDownloading =
|
const isGameDownloading =
|
||||||
game && GameStatusHelper.isDownloading(game.status ?? null);
|
lastPacket?.game && lastPacket?.game.status === "active";
|
||||||
|
|
||||||
const [version, setVersion] = useState("");
|
const [version, setVersion] = useState("");
|
||||||
|
|
||||||
@ -27,17 +26,8 @@ export function BottomPanel() {
|
|||||||
|
|
||||||
const status = useMemo(() => {
|
const status = useMemo(() => {
|
||||||
if (isGameDownloading) {
|
if (isGameDownloading) {
|
||||||
if (game.status === GameStatus.DownloadingMetadata)
|
|
||||||
return t("downloading_metadata", { title: game.title });
|
|
||||||
|
|
||||||
if (game.status === GameStatus.CheckingFiles)
|
|
||||||
return t("checking_files", {
|
|
||||||
title: game.title,
|
|
||||||
percentage: progress,
|
|
||||||
});
|
|
||||||
|
|
||||||
return t("downloading", {
|
return t("downloading", {
|
||||||
title: game?.title,
|
title: lastPacket?.game.title,
|
||||||
percentage: progress,
|
percentage: progress,
|
||||||
eta,
|
eta,
|
||||||
speed: downloadSpeed,
|
speed: downloadSpeed,
|
||||||
@ -45,7 +35,7 @@ export function BottomPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return t("no_downloads_in_progress");
|
return t("no_downloads_in_progress");
|
||||||
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
|
}, [t, isGameDownloading, lastPacket?.game, progress, eta, downloadSpeed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
|
@ -10,7 +10,6 @@ import { useDownload, useLibrary } from "@renderer/hooks";
|
|||||||
import { routes } from "./routes";
|
import { routes } from "./routes";
|
||||||
|
|
||||||
import * as styles from "./sidebar.css";
|
import * as styles from "./sidebar.css";
|
||||||
import { GameStatus, GameStatusHelper } from "@shared";
|
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
@ -35,14 +34,14 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const { game: gameDownloading, progress } = useDownload();
|
const { lastPacket, progress } = useDownload();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateLibrary();
|
updateLibrary();
|
||||||
}, [gameDownloading?.id, updateLibrary]);
|
}, [lastPacket?.game.id, updateLibrary]);
|
||||||
|
|
||||||
const isDownloading = library.some((game) =>
|
const isDownloading = library.some(
|
||||||
GameStatusHelper.isDownloading(game.status)
|
(game) => game.status === "active" && game.progress !== 1
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarRef = useRef<HTMLElement>(null);
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
@ -101,18 +100,9 @@ export function Sidebar() {
|
|||||||
}, [isResizing]);
|
}, [isResizing]);
|
||||||
|
|
||||||
const getGameTitle = (game: Game) => {
|
const getGameTitle = (game: Game) => {
|
||||||
if (game.status === GameStatus.Paused)
|
if (game.status === "paused") return t("paused", { title: game.title });
|
||||||
return t("paused", { title: game.title });
|
|
||||||
|
|
||||||
if (gameDownloading?.id === game.id) {
|
|
||||||
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
|
|
||||||
|
|
||||||
if (isVerifying)
|
|
||||||
return t(gameDownloading.status!, {
|
|
||||||
title: game.title,
|
|
||||||
percentage: progress,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (lastPacket?.game.id === game.id) {
|
||||||
return t("downloading", {
|
return t("downloading", {
|
||||||
title: game.title,
|
title: game.title,
|
||||||
percentage: progress,
|
percentage: progress,
|
||||||
@ -183,7 +173,7 @@ export function Sidebar() {
|
|||||||
className={styles.menuItem({
|
className={styles.menuItem({
|
||||||
active:
|
active:
|
||||||
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
||||||
muted: game.status === GameStatus.Cancelled,
|
muted: game.status === "removed",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@ -7,7 +7,7 @@ import type {
|
|||||||
HowLongToBeatCategory,
|
HowLongToBeatCategory,
|
||||||
ShopDetails,
|
ShopDetails,
|
||||||
Steam250Game,
|
Steam250Game,
|
||||||
TorrentProgress,
|
DownloadProgress,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
@ -31,7 +31,7 @@ declare global {
|
|||||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||||
resumeGameDownload: (gameId: number) => Promise<void>;
|
resumeGameDownload: (gameId: number) => Promise<void>;
|
||||||
onDownloadProgress: (
|
onDownloadProgress: (
|
||||||
cb: (value: TorrentProgress) => void
|
cb: (value: DownloadProgress) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||||
import type { TorrentProgress } from "@types";
|
import type { DownloadProgress } from "@types";
|
||||||
|
|
||||||
export interface DownloadState {
|
export interface DownloadState {
|
||||||
lastPacket: TorrentProgress | null;
|
lastPacket: DownloadProgress | null;
|
||||||
gameId: number | null;
|
gameId: number | null;
|
||||||
gamesWithDeletionInProgress: number[];
|
gamesWithDeletionInProgress: number[];
|
||||||
}
|
}
|
||||||
@ -18,7 +18,7 @@ export const downloadSlice = createSlice({
|
|||||||
name: "download",
|
name: "download",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
|
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
|
||||||
state.lastPacket = action.payload;
|
state.lastPacket = action.payload;
|
||||||
if (!state.gameId) state.gameId = action.payload.game.id;
|
if (!state.gameId) state.gameId = action.payload.game.id;
|
||||||
},
|
},
|
||||||
|
@ -9,9 +9,9 @@ import {
|
|||||||
setGameDeleting,
|
setGameDeleting,
|
||||||
removeGameFromDeleting,
|
removeGameFromDeleting,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import type { GameShop, TorrentProgress } from "@types";
|
import type { DownloadProgress, GameShop } from "@types";
|
||||||
import { useDate } from "./use-date";
|
import { useDate } from "./use-date";
|
||||||
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
import { formatBytes } from "@shared";
|
||||||
|
|
||||||
export function useDownload() {
|
export function useDownload() {
|
||||||
const { updateLibrary } = useLibrary();
|
const { updateLibrary } = useLibrary();
|
||||||
@ -38,16 +38,16 @@ export function useDownload() {
|
|||||||
return game;
|
return game;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pauseDownload = (gameId: number) =>
|
const pauseDownload = async (gameId: number) => {
|
||||||
window.electron.pauseGameDownload(gameId).then(() => {
|
await window.electron.pauseGameDownload(gameId);
|
||||||
dispatch(clearDownload());
|
await updateLibrary();
|
||||||
updateLibrary();
|
dispatch(clearDownload());
|
||||||
});
|
};
|
||||||
|
|
||||||
const resumeDownload = (gameId: number) =>
|
const resumeDownload = async (gameId: number) => {
|
||||||
window.electron.resumeGameDownload(gameId).then(() => {
|
await window.electron.resumeGameDownload(gameId);
|
||||||
updateLibrary();
|
return updateLibrary();
|
||||||
});
|
};
|
||||||
|
|
||||||
const cancelDownload = (gameId: number) =>
|
const cancelDownload = (gameId: number) =>
|
||||||
window.electron.cancelGameDownload(gameId).then(() => {
|
window.electron.cancelGameDownload(gameId).then(() => {
|
||||||
@ -61,14 +61,8 @@ export function useDownload() {
|
|||||||
updateLibrary();
|
updateLibrary();
|
||||||
});
|
});
|
||||||
|
|
||||||
const isVerifying = GameStatusHelper.isVerifying(
|
|
||||||
lastPacket?.game.status ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
const getETA = () => {
|
const getETA = () => {
|
||||||
if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
|
if (lastPacket && lastPacket.timeRemaining < 0) return "";
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return formatDistance(
|
return formatDistance(
|
||||||
@ -81,14 +75,6 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProgress = () => {
|
|
||||||
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
|
|
||||||
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatDownloadProgress(lastPacket?.game.progress);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteGame = (gameId: number) =>
|
const deleteGame = (gameId: number) =>
|
||||||
window.electron
|
window.electron
|
||||||
.cancelGameDownload(gameId)
|
.cancelGameDownload(gameId)
|
||||||
@ -107,15 +93,9 @@ export function useDownload() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
game: lastPacket?.game,
|
|
||||||
bytesDownloaded: lastPacket?.game.bytesDownloaded,
|
|
||||||
fileSize: lastPacket?.game.fileSize,
|
|
||||||
isVerifying,
|
|
||||||
gameId: lastPacket?.game.id,
|
|
||||||
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
||||||
progress: getProgress(),
|
progress: formatDownloadProgress(lastPacket?.game.progress ?? 0),
|
||||||
numPeers: lastPacket?.numPeers,
|
lastPacket,
|
||||||
numSeeds: lastPacket?.numSeeds,
|
|
||||||
eta: getETA(),
|
eta: getETA(),
|
||||||
startDownload,
|
startDownload,
|
||||||
pauseDownload,
|
pauseDownload,
|
||||||
@ -125,6 +105,7 @@ export function useDownload() {
|
|||||||
deleteGame,
|
deleteGame,
|
||||||
isGameDeleting,
|
isGameDeleting,
|
||||||
clearDownload: () => dispatch(clearDownload()),
|
clearDownload: () => dispatch(clearDownload()),
|
||||||
setLastPacket: (packet: TorrentProgress) => dispatch(setLastPacket(packet)),
|
setLastPacket: (packet: DownloadProgress) =>
|
||||||
|
dispatch(setLastPacket(packet)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -26,10 +26,7 @@ export function Downloads() {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
game: gameDownloading,
|
|
||||||
progress,
|
progress,
|
||||||
numPeers,
|
|
||||||
numSeeds,
|
|
||||||
pauseDownload,
|
pauseDownload,
|
||||||
resumeDownload,
|
resumeDownload,
|
||||||
removeGameFromLibrary,
|
removeGameFromLibrary,
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { ShopDetails } from "@types";
|
|
||||||
|
|
||||||
import * as styles from "./game-details.css";
|
import * as styles from "./game-details.css";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { gameDetailsContext } from "./game-details.context";
|
||||||
|
|
||||||
export interface DescriptionHeaderProps {
|
export function DescriptionHeader() {
|
||||||
gameDetails: ShopDetails;
|
const { shopDetails } = useContext(gameDetailsContext);
|
||||||
}
|
|
||||||
|
|
||||||
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
if (!shopDetails) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.descriptionHeader}>
|
<div className={styles.descriptionHeader}>
|
||||||
<section className={styles.descriptionHeaderInfo}>
|
<section className={styles.descriptionHeaderInfo}>
|
||||||
<p>
|
<p>
|
||||||
{t("release_date", {
|
{t("release_date", {
|
||||||
date: gameDetails?.release_date.date,
|
date: shopDetails?.release_date.date,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
|
<p>{t("publisher", { publisher: shopDetails.publishers[0] })}</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,37 +1,36 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
|
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
import type { ShopDetails } from "@types";
|
|
||||||
|
|
||||||
import * as styles from "./gallery-slider.css";
|
import * as styles from "./gallery-slider.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { gameDetailsContext } from "./game-details.context";
|
||||||
|
|
||||||
export interface GallerySliderProps {
|
export function GallerySlider() {
|
||||||
gameDetails: ShopDetails;
|
const { shopDetails } = useContext(gameDetailsContext);
|
||||||
}
|
|
||||||
|
|
||||||
export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const mediaContainerRef = useRef<HTMLDivElement>(null);
|
const mediaContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
|
const hasScreenshots = shopDetails && shopDetails.screenshots.length;
|
||||||
const hasMovies = gameDetails && gameDetails.movies?.length;
|
const hasMovies = shopDetails && shopDetails.movies?.length;
|
||||||
|
|
||||||
const [mediaCount] = useState<number>(() => {
|
const mediaCount = useMemo(() => {
|
||||||
if (gameDetails.screenshots && gameDetails.movies) {
|
if (!shopDetails) return 0;
|
||||||
return gameDetails.screenshots.length + gameDetails.movies.length;
|
|
||||||
} else if (gameDetails.movies) {
|
if (shopDetails.screenshots && shopDetails.movies) {
|
||||||
return gameDetails.movies.length;
|
return shopDetails.screenshots.length + shopDetails.movies.length;
|
||||||
} else if (gameDetails.screenshots) {
|
} else if (shopDetails.movies) {
|
||||||
return gameDetails.screenshots.length;
|
return shopDetails.movies.length;
|
||||||
|
} else if (shopDetails.screenshots) {
|
||||||
|
return shopDetails.screenshots.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
}, [shopDetails]);
|
||||||
|
|
||||||
const [mediaIndex, setMediaIndex] = useState<number>(0);
|
const [mediaIndex, setMediaIndex] = useState(0);
|
||||||
const [showArrows, setShowArrows] = useState(false);
|
const [showArrows, setShowArrows] = useState(false);
|
||||||
|
|
||||||
const showNextImage = () => {
|
const showNextImage = () => {
|
||||||
@ -52,7 +51,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMediaIndex(0);
|
setMediaIndex(0);
|
||||||
}, [gameDetails]);
|
}, [shopDetails]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMovies && mediaContainerRef.current) {
|
if (hasMovies && mediaContainerRef.current) {
|
||||||
@ -76,17 +75,17 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||||||
const scrollLeft = mediaIndex * itemWidth;
|
const scrollLeft = mediaIndex * itemWidth;
|
||||||
container.scrollLeft = scrollLeft;
|
container.scrollLeft = scrollLeft;
|
||||||
}
|
}
|
||||||
}, [gameDetails, mediaIndex, mediaCount]);
|
}, [shopDetails, mediaIndex, mediaCount]);
|
||||||
|
|
||||||
const previews = useMemo(() => {
|
const previews = useMemo(() => {
|
||||||
const screenshotPreviews =
|
const screenshotPreviews =
|
||||||
gameDetails?.screenshots.map(({ id, path_thumbnail }) => ({
|
shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
|
||||||
id,
|
id,
|
||||||
thumbnail: path_thumbnail,
|
thumbnail: path_thumbnail,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
if (gameDetails?.movies) {
|
if (shopDetails?.movies) {
|
||||||
const moviePreviews = gameDetails.movies.map(({ id, thumbnail }) => ({
|
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
|
||||||
id,
|
id,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
}));
|
}));
|
||||||
@ -95,7 +94,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return screenshotPreviews;
|
return screenshotPreviews;
|
||||||
}, [gameDetails]);
|
}, [shopDetails]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -107,8 +106,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||||||
className={styles.gallerySliderAnimationContainer}
|
className={styles.gallerySliderAnimationContainer}
|
||||||
ref={mediaContainerRef}
|
ref={mediaContainerRef}
|
||||||
>
|
>
|
||||||
{gameDetails.movies &&
|
{shopDetails.movies &&
|
||||||
gameDetails.movies.map((video) => (
|
shopDetails.movies.map((video) => (
|
||||||
<video
|
<video
|
||||||
key={video.id}
|
key={video.id}
|
||||||
controls
|
controls
|
||||||
@ -124,7 +123,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{hasScreenshots &&
|
{hasScreenshots &&
|
||||||
gameDetails.screenshots.map((image, i) => (
|
shopDetails.screenshots.map((image, i) => (
|
||||||
<img
|
<img
|
||||||
key={image.id}
|
key={image.id}
|
||||||
className={styles.gallerySliderMedia}
|
className={styles.gallerySliderMedia}
|
||||||
|
206
src/renderer/src/pages/game-details/game-details.context.tsx
Normal file
206
src/renderer/src/pages/game-details/game-details.context.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
|
import { getSteamLanguage } from "@renderer/helpers";
|
||||||
|
import { useAppDispatch, useDownload } from "@renderer/hooks";
|
||||||
|
|
||||||
|
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
DODIInstallationGuide,
|
||||||
|
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
|
||||||
|
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
|
||||||
|
OnlineFixInstallationGuide,
|
||||||
|
RepacksModal,
|
||||||
|
} from "./modals";
|
||||||
|
|
||||||
|
export interface GameDetailsContext {
|
||||||
|
game: Game | null;
|
||||||
|
shopDetails: ShopDetails | null;
|
||||||
|
repacks: GameRepack[];
|
||||||
|
gameTitle: string;
|
||||||
|
isGameRunning: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
objectID: string | undefined;
|
||||||
|
gameColor: string;
|
||||||
|
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
openRepacksModal: () => void;
|
||||||
|
updateGame: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||||
|
game: null,
|
||||||
|
shopDetails: null,
|
||||||
|
repacks: [],
|
||||||
|
gameTitle: "",
|
||||||
|
isGameRunning: false,
|
||||||
|
isLoading: false,
|
||||||
|
objectID: undefined,
|
||||||
|
gameColor: "",
|
||||||
|
setGameColor: () => {},
|
||||||
|
openRepacksModal: () => {},
|
||||||
|
updateGame: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Provider } = gameDetailsContext;
|
||||||
|
export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
|
||||||
|
|
||||||
|
export interface GameDetailsContextProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameDetailsContextProvider({
|
||||||
|
children,
|
||||||
|
}: GameDetailsContextProps) {
|
||||||
|
const { objectID, shop } = useParams();
|
||||||
|
|
||||||
|
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||||
|
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||||
|
const [game, setGame] = useState<Game | null>(null);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [gameColor, setGameColor] = useState("");
|
||||||
|
const [showInstructionsModal, setShowInstructionsModal] = useState<
|
||||||
|
null | "onlinefix" | "DODI"
|
||||||
|
>(null);
|
||||||
|
const [isGameRunning, setisGameRunning] = useState(false);
|
||||||
|
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const gameTitle = searchParams.get("title")!;
|
||||||
|
|
||||||
|
const { i18n } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { startDownload, lastPacket } = useDownload();
|
||||||
|
|
||||||
|
const updateGame = useCallback(async () => {
|
||||||
|
return window.electron
|
||||||
|
.getGameByObjectID(objectID!)
|
||||||
|
.then((result) => setGame(result));
|
||||||
|
}, [setGame, objectID]);
|
||||||
|
|
||||||
|
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateGame();
|
||||||
|
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
window.electron.getGameShopDetails(
|
||||||
|
objectID!,
|
||||||
|
shop as GameShop,
|
||||||
|
getSteamLanguage(i18n.language)
|
||||||
|
),
|
||||||
|
window.electron.searchGameRepacks(gameTitle),
|
||||||
|
])
|
||||||
|
.then(([appDetails, repacks]) => {
|
||||||
|
if (appDetails) setGameDetails(appDetails);
|
||||||
|
setRepacks(repacks);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateGame();
|
||||||
|
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGameDetails(null);
|
||||||
|
setGame(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
setisGameRunning(false);
|
||||||
|
dispatch(setHeaderTitle(gameTitle));
|
||||||
|
}, [objectID, gameTitle, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listeners = [
|
||||||
|
window.electron.onGameClose(() => {
|
||||||
|
if (isGameRunning) setisGameRunning(false);
|
||||||
|
}),
|
||||||
|
window.electron.onPlaytime((gameId) => {
|
||||||
|
if (gameId === game?.id) {
|
||||||
|
if (!isGameRunning) setisGameRunning(true);
|
||||||
|
updateGame();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.forEach((unsubscribe) => unsubscribe());
|
||||||
|
};
|
||||||
|
}, [game?.id, isGameRunning, updateGame]);
|
||||||
|
|
||||||
|
const handleStartDownload = async (
|
||||||
|
repack: GameRepack,
|
||||||
|
downloadPath: string
|
||||||
|
) => {
|
||||||
|
await startDownload(
|
||||||
|
repack.id,
|
||||||
|
objectID!,
|
||||||
|
gameTitle,
|
||||||
|
shop as GameShop,
|
||||||
|
downloadPath
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateGame();
|
||||||
|
setShowRepacksModal(false);
|
||||||
|
|
||||||
|
if (
|
||||||
|
repack.repacker === "onlinefix" &&
|
||||||
|
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
|
||||||
|
) {
|
||||||
|
setShowInstructionsModal("onlinefix");
|
||||||
|
} else if (
|
||||||
|
repack.repacker === "DODI" &&
|
||||||
|
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
|
||||||
|
) {
|
||||||
|
setShowInstructionsModal("DODI");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRepacksModal = () => setShowRepacksModal(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider
|
||||||
|
value={{
|
||||||
|
game,
|
||||||
|
shopDetails,
|
||||||
|
repacks,
|
||||||
|
gameTitle,
|
||||||
|
isGameRunning,
|
||||||
|
isLoading,
|
||||||
|
objectID,
|
||||||
|
gameColor,
|
||||||
|
setGameColor,
|
||||||
|
openRepacksModal,
|
||||||
|
updateGame,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<RepacksModal
|
||||||
|
visible={showRepacksModal}
|
||||||
|
startDownload={handleStartDownload}
|
||||||
|
onClose={() => setShowRepacksModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OnlineFixInstallationGuide
|
||||||
|
visible={showInstructionsModal === "onlinefix"}
|
||||||
|
onClose={() => setShowInstructionsModal(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DODIInstallationGuide
|
||||||
|
visible={showInstructionsModal === "DODI"}
|
||||||
|
onClose={() => setShowInstructionsModal(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
@ -1,24 +1,11 @@
|
|||||||
import Color from "color";
|
import { useEffect, useState } from "react";
|
||||||
import { average } from "color.js";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { average } from "color.js";
|
||||||
|
|
||||||
import {
|
import { Steam250Game } from "@types";
|
||||||
Steam250Game,
|
|
||||||
type Game,
|
|
||||||
type GameRepack,
|
|
||||||
type GameShop,
|
|
||||||
type ShopDetails,
|
|
||||||
} from "@types";
|
|
||||||
|
|
||||||
import { Button } from "@renderer/components";
|
import { Button } from "@renderer/components";
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
|
||||||
import {
|
|
||||||
buildGameDetailsPath,
|
|
||||||
getSteamLanguage,
|
|
||||||
steamUrlBuilder,
|
|
||||||
} from "@renderer/helpers";
|
|
||||||
import { useAppDispatch, useDownload } from "@renderer/hooks";
|
|
||||||
|
|
||||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||||
|
|
||||||
@ -29,153 +16,34 @@ import { DescriptionHeader } from "./description-header";
|
|||||||
import { GameDetailsSkeleton } from "./game-details-skeleton";
|
import { GameDetailsSkeleton } from "./game-details-skeleton";
|
||||||
import * as styles from "./game-details.css";
|
import * as styles from "./game-details.css";
|
||||||
import { HeroPanel } from "./hero";
|
import { HeroPanel } from "./hero";
|
||||||
import { RepacksModal } from "./repacks-modal";
|
|
||||||
|
|
||||||
import { vars } from "../../theme.css";
|
import { vars } from "../../theme.css";
|
||||||
import {
|
|
||||||
DODIInstallationGuide,
|
|
||||||
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
|
|
||||||
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
|
|
||||||
OnlineFixInstallationGuide,
|
|
||||||
} from "./installation-guides";
|
|
||||||
import { GallerySlider } from "./gallery-slider";
|
import { GallerySlider } from "./gallery-slider";
|
||||||
import { Sidebar } from "./sidebar/sidebar";
|
import { Sidebar } from "./sidebar/sidebar";
|
||||||
|
import {
|
||||||
|
GameDetailsContextConsumer,
|
||||||
|
GameDetailsContextProvider,
|
||||||
|
} from "./game-details.context";
|
||||||
|
|
||||||
export function GameDetails() {
|
export function GameDetails() {
|
||||||
const { objectID, shop } = useParams();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
||||||
const [color, setColor] = useState({ dark: "", light: "" });
|
|
||||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
|
||||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
|
||||||
|
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
const { objectID } = useParams();
|
||||||
const [isGamePlaying, setIsGamePlaying] = useState(false);
|
|
||||||
const [showInstructionsModal, setShowInstructionsModal] = useState<
|
|
||||||
null | "onlinefix" | "DODI"
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const fromRandomizer = searchParams.get("fromRandomizer");
|
const fromRandomizer = searchParams.get("fromRandomizer");
|
||||||
const title = searchParams.get("title")!;
|
|
||||||
|
|
||||||
const { t, i18n } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const { game: gameDownloading, startDownload } = useDownload();
|
|
||||||
|
|
||||||
const heroImage = steamUrlBuilder.libraryHero(objectID!);
|
|
||||||
|
|
||||||
const handleHeroLoad = () => {
|
|
||||||
average(heroImage, { amount: 1, format: "hex" })
|
|
||||||
.then((color) => {
|
|
||||||
const darkColor = new Color(color).darken(0.6).toString() as string;
|
|
||||||
setColor({ light: color as string, dark: darkColor });
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGame = useCallback(() => {
|
|
||||||
window.electron
|
|
||||||
.getGameByObjectID(objectID!)
|
|
||||||
.then((result) => setGame(result));
|
|
||||||
}, [setGame, objectID]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getGame();
|
setRandomGame(null);
|
||||||
}, [getGame, gameDownloading?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setGameDetails(null);
|
|
||||||
setGame(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
setIsGamePlaying(false);
|
|
||||||
dispatch(setHeaderTitle(title));
|
|
||||||
|
|
||||||
window.electron.getRandomGame().then((randomGame) => {
|
window.electron.getRandomGame().then((randomGame) => {
|
||||||
setRandomGame(randomGame);
|
setRandomGame(randomGame);
|
||||||
});
|
});
|
||||||
|
}, [objectID]);
|
||||||
Promise.all([
|
|
||||||
window.electron.getGameShopDetails(
|
|
||||||
objectID!,
|
|
||||||
"steam",
|
|
||||||
getSteamLanguage(i18n.language)
|
|
||||||
),
|
|
||||||
window.electron.searchGameRepacks(title),
|
|
||||||
])
|
|
||||||
.then(([appDetails, repacks]) => {
|
|
||||||
if (appDetails) setGameDetails(appDetails);
|
|
||||||
setRepacks(repacks);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
getGame();
|
|
||||||
}, [getGame, dispatch, navigate, title, objectID, i18n.language]);
|
|
||||||
|
|
||||||
const isGameDownloading = gameDownloading?.id === game?.id;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isGameDownloading)
|
|
||||||
setGame((prev) => {
|
|
||||||
if (prev === null || !gameDownloading?.status) return prev;
|
|
||||||
return { ...prev, status: gameDownloading?.status };
|
|
||||||
});
|
|
||||||
}, [isGameDownloading, gameDownloading?.status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listeners = [
|
|
||||||
window.electron.onGameClose(() => {
|
|
||||||
if (isGamePlaying) setIsGamePlaying(false);
|
|
||||||
}),
|
|
||||||
window.electron.onPlaytime((gameId) => {
|
|
||||||
if (gameId === game?.id) {
|
|
||||||
if (!isGamePlaying) setIsGamePlaying(true);
|
|
||||||
getGame();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
listeners.forEach((unsubscribe) => unsubscribe());
|
|
||||||
};
|
|
||||||
}, [game?.id, isGamePlaying, getGame]);
|
|
||||||
|
|
||||||
const handleStartDownload = async (
|
|
||||||
repack: GameRepack,
|
|
||||||
downloadPath: string
|
|
||||||
) => {
|
|
||||||
return startDownload(
|
|
||||||
repack.id,
|
|
||||||
objectID!,
|
|
||||||
title,
|
|
||||||
shop as GameShop,
|
|
||||||
downloadPath
|
|
||||||
).then(() => {
|
|
||||||
getGame();
|
|
||||||
setShowRepacksModal(false);
|
|
||||||
|
|
||||||
if (
|
|
||||||
repack.repacker === "onlinefix" &&
|
|
||||||
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
|
|
||||||
) {
|
|
||||||
setShowInstructionsModal("onlinefix");
|
|
||||||
} else if (
|
|
||||||
repack.repacker === "DODI" &&
|
|
||||||
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
|
|
||||||
) {
|
|
||||||
setShowInstructionsModal("DODI");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRandomizerClick = () => {
|
const handleRandomizerClick = () => {
|
||||||
if (randomGame) {
|
if (randomGame) {
|
||||||
@ -189,97 +57,95 @@ export function GameDetails() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
<GameDetailsContextProvider>
|
||||||
<RepacksModal
|
<GameDetailsContextConsumer>
|
||||||
visible={showRepacksModal}
|
{({ game, shopDetails, isLoading, setGameColor }) => {
|
||||||
repacks={repacks}
|
const handleHeroLoad = async () => {
|
||||||
startDownload={handleStartDownload}
|
const output = await average(
|
||||||
onClose={() => setShowRepacksModal(false)}
|
steamUrlBuilder.libraryHero(objectID!),
|
||||||
/>
|
{
|
||||||
|
amount: 1,
|
||||||
|
format: "hex",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
<OnlineFixInstallationGuide
|
setGameColor(output as string);
|
||||||
visible={showInstructionsModal === "onlinefix"}
|
};
|
||||||
onClose={() => setShowInstructionsModal(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DODIInstallationGuide
|
return (
|
||||||
windowColor={color.light}
|
<SkeletonTheme
|
||||||
visible={showInstructionsModal === "DODI"}
|
baseColor={vars.color.background}
|
||||||
onClose={() => setShowInstructionsModal(null)}
|
highlightColor="#444"
|
||||||
/>
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<GameDetailsSkeleton />
|
||||||
|
) : (
|
||||||
|
<section className={styles.container}>
|
||||||
|
<div className={styles.hero}>
|
||||||
|
<img
|
||||||
|
src={steamUrlBuilder.libraryHero(objectID!)}
|
||||||
|
className={styles.heroImage}
|
||||||
|
alt={game?.title}
|
||||||
|
onLoad={handleHeroLoad}
|
||||||
|
/>
|
||||||
|
<div className={styles.heroBackdrop}>
|
||||||
|
<div className={styles.heroContent}>
|
||||||
|
<img
|
||||||
|
src={steamUrlBuilder.logo(objectID!)}
|
||||||
|
style={{ width: 300, alignSelf: "flex-end" }}
|
||||||
|
alt={game?.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<HeroPanel />
|
||||||
<GameDetailsSkeleton />
|
|
||||||
) : (
|
|
||||||
<section className={styles.container}>
|
|
||||||
<div className={styles.hero}>
|
|
||||||
<img
|
|
||||||
src={heroImage}
|
|
||||||
className={styles.heroImage}
|
|
||||||
alt={game?.title}
|
|
||||||
onLoad={handleHeroLoad}
|
|
||||||
/>
|
|
||||||
<div className={styles.heroBackdrop}>
|
|
||||||
<div className={styles.heroContent}>
|
|
||||||
<img
|
|
||||||
src={steamUrlBuilder.logo(objectID!)}
|
|
||||||
style={{ width: 300, alignSelf: "flex-end" }}
|
|
||||||
alt={game?.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HeroPanel
|
<div className={styles.descriptionContainer}>
|
||||||
game={game}
|
<div className={styles.descriptionContent}>
|
||||||
color={color.dark}
|
<DescriptionHeader />
|
||||||
objectID={objectID!}
|
<GallerySlider />
|
||||||
title={title}
|
|
||||||
repacks={repacks}
|
|
||||||
openRepacksModal={() => setShowRepacksModal(true)}
|
|
||||||
getGame={getGame}
|
|
||||||
isGamePlaying={isGamePlaying}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.descriptionContainer}>
|
<div
|
||||||
<div className={styles.descriptionContent}>
|
dangerouslySetInnerHTML={{
|
||||||
{gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
|
__html:
|
||||||
{gameDetails && <GallerySlider gameDetails={gameDetails} />}
|
shopDetails?.about_the_game ?? t("no_shop_details"),
|
||||||
|
}}
|
||||||
|
className={styles.description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<Sidebar />
|
||||||
dangerouslySetInnerHTML={{
|
</div>
|
||||||
__html: gameDetails?.about_the_game ?? t("no_shop_details"),
|
</section>
|
||||||
}}
|
)}
|
||||||
className={styles.description}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sidebar
|
{fromRandomizer && (
|
||||||
objectID={objectID!}
|
<Button
|
||||||
title={title}
|
className={styles.randomizerButton}
|
||||||
gameDetails={gameDetails}
|
onClick={handleRandomizerClick}
|
||||||
/>
|
theme="outline"
|
||||||
</div>
|
disabled={!randomGame}
|
||||||
</section>
|
>
|
||||||
)}
|
<div style={{ width: 16, height: 16, position: "relative" }}>
|
||||||
|
<Lottie
|
||||||
{fromRandomizer && (
|
animationData={starsAnimation}
|
||||||
<Button
|
style={{
|
||||||
className={styles.randomizerButton}
|
width: 70,
|
||||||
onClick={handleRandomizerClick}
|
position: "absolute",
|
||||||
theme="outline"
|
top: -28,
|
||||||
disabled={!randomGame}
|
left: -27,
|
||||||
>
|
}}
|
||||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
loop
|
||||||
<Lottie
|
/>
|
||||||
animationData={starsAnimation}
|
</div>
|
||||||
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
|
{t("next_suggestion")}
|
||||||
loop
|
</Button>
|
||||||
/>
|
)}
|
||||||
</div>
|
</SkeletonTheme>
|
||||||
{t("next_suggestion")}
|
);
|
||||||
</Button>
|
}}
|
||||||
)}
|
</GameDetailsContextConsumer>
|
||||||
</SkeletonTheme>
|
</GameDetailsContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,14 @@
|
|||||||
import { GameStatus, GameStatusHelper } from "@shared";
|
|
||||||
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
|
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
import { Button } from "@renderer/components";
|
import { Button } from "@renderer/components";
|
||||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||||
import type { Game, GameRepack } from "@types";
|
import { useContext, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import * as styles from "./hero-panel-actions.css";
|
import * as styles from "./hero-panel-actions.css";
|
||||||
|
import { gameDetailsContext } from "../game-details.context";
|
||||||
|
|
||||||
export interface HeroPanelActionsProps {
|
export function HeroPanelActions() {
|
||||||
game: Game | null;
|
|
||||||
repacks: GameRepack[];
|
|
||||||
isGamePlaying: boolean;
|
|
||||||
isGameDownloading: boolean;
|
|
||||||
objectID: string;
|
|
||||||
title: string;
|
|
||||||
openRepacksModal: () => void;
|
|
||||||
openBinaryNotFoundModal: () => void;
|
|
||||||
getGame: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroPanelActions({
|
|
||||||
game,
|
|
||||||
isGamePlaying,
|
|
||||||
isGameDownloading,
|
|
||||||
repacks,
|
|
||||||
objectID,
|
|
||||||
title,
|
|
||||||
openRepacksModal,
|
|
||||||
openBinaryNotFoundModal,
|
|
||||||
getGame,
|
|
||||||
}: HeroPanelActionsProps) {
|
|
||||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
@ -43,6 +20,16 @@ export function HeroPanelActions({
|
|||||||
isGameDeleting,
|
isGameDeleting,
|
||||||
} = useDownload();
|
} = useDownload();
|
||||||
|
|
||||||
|
const {
|
||||||
|
game,
|
||||||
|
repacks,
|
||||||
|
isGameRunning,
|
||||||
|
objectID,
|
||||||
|
gameTitle,
|
||||||
|
openRepacksModal,
|
||||||
|
updateGame,
|
||||||
|
} = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { updateLibrary } = useLibrary();
|
const { updateLibrary } = useLibrary();
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
@ -86,15 +73,15 @@ export function HeroPanelActions({
|
|||||||
const gameExecutablePath = await selectGameExecutable();
|
const gameExecutablePath = await selectGameExecutable();
|
||||||
|
|
||||||
await window.electron.addGameToLibrary(
|
await window.electron.addGameToLibrary(
|
||||||
objectID,
|
objectID!,
|
||||||
title,
|
gameTitle,
|
||||||
"steam",
|
"steam",
|
||||||
gameExecutablePath
|
gameExecutablePath
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLibrary();
|
updateLibrary();
|
||||||
getGame();
|
updateGame();
|
||||||
} finally {
|
} finally {
|
||||||
setToggleLibraryGameDisabled(false);
|
setToggleLibraryGameDisabled(false);
|
||||||
}
|
}
|
||||||
@ -145,59 +132,14 @@ export function HeroPanelActions({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (game && isGameDownloading) {
|
if (game?.progress === 1) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
{game?.progress === 1 ? (
|
||||||
onClick={() => pauseDownload(game.id)}
|
|
||||||
theme="outline"
|
|
||||||
className={styles.heroPanelAction}
|
|
||||||
>
|
|
||||||
{t("pause")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => cancelDownload(game.id)}
|
|
||||||
theme="outline"
|
|
||||||
className={styles.heroPanelAction}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game?.status === GameStatus.Paused) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
onClick={() => resumeDownload(game.id)}
|
|
||||||
theme="outline"
|
|
||||||
className={styles.heroPanelAction}
|
|
||||||
>
|
|
||||||
{t("resume")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => cancelDownload(game.id).then(getGame)}
|
|
||||||
theme="outline"
|
|
||||||
className={styles.heroPanelAction}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
GameStatusHelper.isReady(game?.status ?? null) ||
|
|
||||||
(game && !game.status)
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{GameStatusHelper.isReady(game?.status ?? null) ? (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={openGameInstaller}
|
onClick={openGameInstaller}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
disabled={deleting || isGamePlaying}
|
disabled={deleting || isGameRunning}
|
||||||
className={styles.heroPanelAction}
|
className={styles.heroPanelAction}
|
||||||
>
|
>
|
||||||
{t("install")}
|
{t("install")}
|
||||||
@ -206,7 +148,7 @@ export function HeroPanelActions({
|
|||||||
toggleGameOnLibraryButton
|
toggleGameOnLibraryButton
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isGamePlaying ? (
|
{isGameRunning ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={closeGame}
|
onClick={closeGame}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
@ -219,7 +161,7 @@ export function HeroPanelActions({
|
|||||||
<Button
|
<Button
|
||||||
onClick={openGame}
|
onClick={openGame}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
disabled={deleting || isGamePlaying}
|
disabled={deleting || isGameRunning}
|
||||||
className={styles.heroPanelAction}
|
className={styles.heroPanelAction}
|
||||||
>
|
>
|
||||||
{t("play")}
|
{t("play")}
|
||||||
@ -229,7 +171,49 @@ export function HeroPanelActions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game?.status === GameStatus.Cancelled) {
|
if (game?.status === "active") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => pauseDownload(game.id).then(updateGame)}
|
||||||
|
theme="outline"
|
||||||
|
className={styles.heroPanelAction}
|
||||||
|
>
|
||||||
|
{t("pause")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => cancelDownload(game.id).then(updateGame)}
|
||||||
|
theme="outline"
|
||||||
|
className={styles.heroPanelAction}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game?.status === "paused") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => resumeDownload(game.id).then(updateGame)}
|
||||||
|
theme="outline"
|
||||||
|
className={styles.heroPanelAction}
|
||||||
|
>
|
||||||
|
{t("resume")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => cancelDownload(game.id).then(updateGame)}
|
||||||
|
theme="outline"
|
||||||
|
className={styles.heroPanelAction}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game?.status === "removed") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@ -240,8 +224,9 @@ export function HeroPanelActions({
|
|||||||
>
|
>
|
||||||
{t("open_download_options")}
|
{t("open_download_options")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => removeGameFromLibrary(game.id).then(getGame)}
|
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
|
||||||
theme="outline"
|
theme="outline"
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className={styles.heroPanelAction}
|
className={styles.heroPanelAction}
|
||||||
|
@ -1,22 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { Game } from "@types";
|
|
||||||
import { useDate } from "@renderer/hooks";
|
import { useDate } from "@renderer/hooks";
|
||||||
|
import { gameDetailsContext } from "../game-details.context";
|
||||||
|
|
||||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||||
|
|
||||||
export interface HeroPanelPlaytimeProps {
|
export function HeroPanelPlaytime() {
|
||||||
game: Game;
|
|
||||||
isGamePlaying: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroPanelPlaytime({
|
|
||||||
game,
|
|
||||||
isGamePlaying,
|
|
||||||
}: HeroPanelPlaytimeProps) {
|
|
||||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||||
|
|
||||||
|
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { i18n, t } = useTranslation("game_details");
|
const { i18n, t } = useTranslation("game_details");
|
||||||
|
|
||||||
const { formatDistance } = useDate();
|
const { formatDistance } = useDate();
|
||||||
@ -52,8 +46,8 @@ export function HeroPanelPlaytime({
|
|||||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!game.lastTimePlayed) {
|
if (!game?.lastTimePlayed) {
|
||||||
return <p>{t("not_played_yet", { title: game.title })}</p>;
|
return <p>{t("not_played_yet", { title: game?.title })}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -64,7 +58,7 @@ export function HeroPanelPlaytime({
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isGamePlaying ? (
|
{isGameRunning ? (
|
||||||
<p>{t("playing_now")}</p>
|
<p>{t("playing_now")}</p>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
|
@ -1,72 +1,48 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useMemo, useState } from "react";
|
import { useContext, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Color from "color";
|
||||||
|
|
||||||
import { useDownload } from "@renderer/hooks";
|
import { useDownload } from "@renderer/hooks";
|
||||||
import type { Game, GameRepack } from "@types";
|
|
||||||
|
|
||||||
import { formatDownloadProgress } from "@renderer/helpers";
|
import { formatDownloadProgress } from "@renderer/helpers";
|
||||||
import { HeroPanelActions } from "./hero-panel-actions";
|
import { HeroPanelActions } from "./hero-panel-actions";
|
||||||
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
|
import { Downloader, formatBytes } from "@shared";
|
||||||
|
|
||||||
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
|
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
|
||||||
import * as styles from "./hero-panel.css";
|
import * as styles from "./hero-panel.css";
|
||||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||||
|
import { gameDetailsContext } from "../game-details.context";
|
||||||
|
|
||||||
export interface HeroPanelProps {
|
export function HeroPanel() {
|
||||||
game: Game | null;
|
|
||||||
color: string;
|
|
||||||
isGamePlaying: boolean;
|
|
||||||
objectID: string;
|
|
||||||
title: string;
|
|
||||||
repacks: GameRepack[];
|
|
||||||
openRepacksModal: () => void;
|
|
||||||
getGame: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroPanel({
|
|
||||||
game,
|
|
||||||
color,
|
|
||||||
repacks,
|
|
||||||
objectID,
|
|
||||||
title,
|
|
||||||
isGamePlaying,
|
|
||||||
openRepacksModal,
|
|
||||||
getGame,
|
|
||||||
}: HeroPanelProps) {
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const { game, repacks, gameColor } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||||
|
|
||||||
const {
|
const { progress, eta, lastPacket, isGameDeleting } = useDownload();
|
||||||
game: gameDownloading,
|
|
||||||
progress,
|
|
||||||
eta,
|
|
||||||
numPeers,
|
|
||||||
numSeeds,
|
|
||||||
isGameDeleting,
|
|
||||||
} = useDownload();
|
|
||||||
|
|
||||||
const isGameDownloading =
|
|
||||||
gameDownloading?.id === game?.id &&
|
|
||||||
GameStatusHelper.isDownloading(game?.status ?? null);
|
|
||||||
|
|
||||||
const finalDownloadSize = useMemo(() => {
|
const finalDownloadSize = useMemo(() => {
|
||||||
if (!game) return "N/A";
|
if (!game) return "N/A";
|
||||||
if (game.fileSize) return formatBytes(game.fileSize);
|
if (game.fileSize) return formatBytes(game.fileSize);
|
||||||
|
|
||||||
if (gameDownloading?.fileSize && isGameDownloading)
|
if (lastPacket?.game.fileSize && game?.status === "active")
|
||||||
return formatBytes(gameDownloading.fileSize);
|
return formatBytes(lastPacket?.game.fileSize);
|
||||||
|
|
||||||
return game.repack?.fileSize ?? "N/A";
|
return game.repack?.fileSize ?? "N/A";
|
||||||
}, [game, isGameDownloading, gameDownloading]);
|
}, [game, lastPacket?.game]);
|
||||||
|
|
||||||
const getInfo = () => {
|
const getInfo = () => {
|
||||||
if (isGameDeleting(game?.id ?? -1)) {
|
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>;
|
||||||
return <p>{t("deleting")}</p>;
|
|
||||||
}
|
if (game?.progress === 1) return <HeroPanelPlaytime />;
|
||||||
|
|
||||||
|
if (game?.status === "active") {
|
||||||
|
if (lastPacket?.downloadingMetadata) {
|
||||||
|
return <p>{t("downloading_metadata")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
if (isGameDownloading && gameDownloading?.status) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className={styles.downloadDetailsRow}>
|
<p className={styles.downloadDetailsRow}>
|
||||||
@ -74,33 +50,25 @@ export function HeroPanel({
|
|||||||
{eta && <small>{t("eta", { eta })}</small>}
|
{eta && <small>{t("eta", { eta })}</small>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{gameDownloading.status !== GameStatus.Downloading ? (
|
<p className={styles.downloadDetailsRow}>
|
||||||
<>
|
{formatBytes(lastPacket?.game?.bytesDownloaded ?? 0)} /{" "}
|
||||||
<p>{t(gameDownloading.status)}</p>
|
{finalDownloadSize}
|
||||||
{eta && <small>{t("eta", { eta })}</small>}
|
{game?.downloader === Downloader.Torrent && (
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className={styles.downloadDetailsRow}>
|
|
||||||
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
|
|
||||||
{finalDownloadSize}
|
|
||||||
<small>
|
<small>
|
||||||
{game?.downloader === Downloader.Torrent &&
|
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||||
`${numPeers} peers / ${numSeeds} seeds`}
|
|
||||||
</small>
|
</small>
|
||||||
</p>
|
)}
|
||||||
)}
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game?.status === GameStatus.Paused) {
|
if (game?.status === "paused") {
|
||||||
|
const formattedProgress = formatDownloadProgress(game.progress);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>{t("paused_progress", { progress: formattedProgress })}</p>
|
||||||
{t("paused_progress", {
|
|
||||||
progress: formatDownloadProgress(game.progress),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p>
|
<p>
|
||||||
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||||
</p>
|
</p>
|
||||||
@ -108,10 +76,6 @@ export function HeroPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game && GameStatusHelper.isReady(game?.status ?? GameStatus.Finished)) {
|
|
||||||
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [latestRepack] = repacks;
|
const [latestRepack] = repacks;
|
||||||
|
|
||||||
if (latestRepack) {
|
if (latestRepack) {
|
||||||
@ -129,6 +93,10 @@ export function HeroPanel({
|
|||||||
return <p>{t("no_downloads")}</p>;
|
return <p>{t("no_downloads")}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backgroundColor = gameColor
|
||||||
|
? (new Color(gameColor).darken(0.6).toString() as string)
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BinaryNotFoundModal
|
<BinaryNotFoundModal
|
||||||
@ -136,19 +104,11 @@ export function HeroPanel({
|
|||||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ backgroundColor: color }} className={styles.panel}>
|
<div style={{ backgroundColor }} className={styles.panel}>
|
||||||
<div className={styles.content}>{getInfo()}</div>
|
<div className={styles.content}>{getInfo()}</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<HeroPanelActions
|
<HeroPanelActions
|
||||||
game={game}
|
|
||||||
repacks={repacks}
|
|
||||||
objectID={objectID}
|
|
||||||
title={title}
|
|
||||||
getGame={getGame}
|
|
||||||
openRepacksModal={openRepacksModal}
|
|
||||||
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
|
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
|
||||||
isGamePlaying={isGamePlaying}
|
|
||||||
isGameDownloading={isGameDownloading}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
3
src/renderer/src/pages/game-details/modals/index.ts
Normal file
3
src/renderer/src/pages/game-details/modals/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./installation-guides";
|
||||||
|
export * from "./repacks-modal";
|
||||||
|
export * from "./select-folder-modal";
|
@ -1,4 +1,4 @@
|
|||||||
import { vars } from "../../../theme.css";
|
import { vars } from "../../../../theme.css";
|
||||||
import { keyframes, style } from "@vanilla-extract/css";
|
import { keyframes, style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
export const slideIn = keyframes({
|
export const slideIn = keyframes({
|
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button, CheckboxField, Modal } from "@renderer/components";
|
import { Button, CheckboxField, Modal } from "@renderer/components";
|
||||||
@ -7,18 +7,19 @@ import { SPACING_UNIT } from "@renderer/theme.css";
|
|||||||
import * as styles from "./dodi-installation-guide.css";
|
import * as styles from "./dodi-installation-guide.css";
|
||||||
import { ArrowUpIcon } from "@primer/octicons-react";
|
import { ArrowUpIcon } from "@primer/octicons-react";
|
||||||
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
|
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
|
||||||
|
import { gameDetailsContext } from "../../game-details.context";
|
||||||
|
|
||||||
export interface DODIInstallationGuideProps {
|
export interface DODIInstallationGuideProps {
|
||||||
windowColor: string;
|
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DODIInstallationGuide({
|
export function DODIInstallationGuide({
|
||||||
windowColor,
|
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
}: DODIInstallationGuideProps) {
|
}: DODIInstallationGuideProps) {
|
||||||
|
const { gameColor } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||||
@ -53,7 +54,7 @@ export function DODIInstallationGuide({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.windowContainer}
|
className={styles.windowContainer}
|
||||||
style={{ backgroundColor: windowColor }}
|
style={{ backgroundColor: gameColor }}
|
||||||
>
|
>
|
||||||
<div className={styles.windowContent}>
|
<div className={styles.windowContent}>
|
||||||
<ArrowUpIcon size={24} />
|
<ArrowUpIcon size={24} />
|
@ -1,4 +1,4 @@
|
|||||||
import { SPACING_UNIT } from "../../../theme.css";
|
import { SPACING_UNIT } from "../../../../theme.css";
|
||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
export const passwordField = style({
|
export const passwordField = style({
|
@ -1,5 +1,5 @@
|
|||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
|
||||||
export const repacks = style({
|
export const repacks = style({
|
||||||
display: "flex",
|
display: "flex",
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button, Modal, TextField } from "@renderer/components";
|
import { Button, Modal, TextField } from "@renderer/components";
|
||||||
@ -6,20 +6,19 @@ import type { GameRepack } from "@types";
|
|||||||
|
|
||||||
import * as styles from "./repacks-modal.css";
|
import * as styles from "./repacks-modal.css";
|
||||||
|
|
||||||
import { SPACING_UNIT } from "../../theme.css";
|
import { SPACING_UNIT } from "../../../theme.css";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { SelectFolderModal } from "./select-folder-modal";
|
import { SelectFolderModal } from "./select-folder-modal";
|
||||||
|
import { gameDetailsContext } from "../game-details.context";
|
||||||
|
|
||||||
export interface RepacksModalProps {
|
export interface RepacksModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
repacks: GameRepack[];
|
|
||||||
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
|
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepacksModal({
|
export function RepacksModal({
|
||||||
visible,
|
visible,
|
||||||
repacks,
|
|
||||||
startDownload,
|
startDownload,
|
||||||
onClose,
|
onClose,
|
||||||
}: RepacksModalProps) {
|
}: RepacksModalProps) {
|
||||||
@ -27,6 +26,8 @@ export function RepacksModal({
|
|||||||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||||
|
|
||||||
|
const { repacks } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
@ -1,5 +1,5 @@
|
|||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
|
||||||
export const container = style({
|
export const container = style({
|
||||||
display: "flex",
|
display: "flex",
|
@ -1,13 +1,14 @@
|
|||||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
|
||||||
import type { GameRepack } from "@types";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { DiskSpace } from "check-disk-space";
|
import { DiskSpace } from "check-disk-space";
|
||||||
import * as styles from "./select-folder-modal.css";
|
import * as styles from "./select-folder-modal.css";
|
||||||
|
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||||
import { DownloadIcon } from "@primer/octicons-react";
|
import { DownloadIcon } from "@primer/octicons-react";
|
||||||
import { formatBytes } from "@shared";
|
import { formatBytes } from "@shared";
|
||||||
|
|
||||||
|
import type { GameRepack } from "@types";
|
||||||
|
|
||||||
export interface SelectFolderModalProps {
|
export interface SelectFolderModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
@ -1,22 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||||
import type {
|
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
|
||||||
HowLongToBeatCategory,
|
|
||||||
ShopDetails,
|
|
||||||
SteamAppDetails,
|
|
||||||
} from "@types";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@renderer/components";
|
import { Button } from "@renderer/components";
|
||||||
|
|
||||||
import * as styles from "./sidebar.css";
|
import * as styles from "./sidebar.css";
|
||||||
|
import { gameDetailsContext } from "../game-details.context";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export function Sidebar() {
|
||||||
objectID: string;
|
|
||||||
title: string;
|
|
||||||
gameDetails: ShopDetails | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
|
|
||||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
data: HowLongToBeatCategory[] | null;
|
data: HowLongToBeatCategory[] | null;
|
||||||
@ -25,20 +16,24 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
|
|||||||
const [activeRequirement, setActiveRequirement] =
|
const [activeRequirement, setActiveRequirement] =
|
||||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||||
|
|
||||||
|
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
|
||||||
|
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHowLongToBeat({ isLoading: true, data: null });
|
if (objectID) {
|
||||||
|
setHowLongToBeat({ isLoading: true, data: null });
|
||||||
|
|
||||||
window.electron
|
window.electron
|
||||||
.getHowLongToBeat(objectID, "steam", title)
|
.getHowLongToBeat(objectID, "steam", gameTitle)
|
||||||
.then((howLongToBeat) => {
|
.then((howLongToBeat) => {
|
||||||
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setHowLongToBeat({ isLoading: false, data: null });
|
setHowLongToBeat({ isLoading: false, data: null });
|
||||||
});
|
});
|
||||||
}, [objectID, title]);
|
}
|
||||||
|
}, [objectID, gameTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.contentSidebar}>
|
<aside className={styles.contentSidebar}>
|
||||||
@ -73,9 +68,9 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
|
|||||||
className={styles.requirementsDetails}
|
className={styles.requirementsDetails}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html:
|
||||||
gameDetails?.pc_requirements?.[activeRequirement] ??
|
shopDetails?.pc_requirements?.[activeRequirement] ??
|
||||||
t(`no_${activeRequirement}_requirements`, {
|
t(`no_${activeRequirement}_requirements`, {
|
||||||
title,
|
gameTitle,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -31,22 +31,11 @@ export const formatBytes = (bytes: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class GameStatusHelper {
|
export class GameStatusHelper {
|
||||||
public static isDownloading(status: GameStatus | null) {
|
public static isDownloading(status: string | null) {
|
||||||
return (
|
return status === "active";
|
||||||
status === GameStatus.Downloading ||
|
|
||||||
status === GameStatus.DownloadingMetadata ||
|
|
||||||
status === GameStatus.CheckingFiles
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static isVerifying(status: GameStatus | null) {
|
public static isReady(status: string | null) {
|
||||||
return (
|
return status === "complete";
|
||||||
GameStatus.DownloadingMetadata == status ||
|
|
||||||
GameStatus.CheckingFiles == status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static isReady(status: GameStatus | null) {
|
|
||||||
return status === GameStatus.Finished || status === GameStatus.Seeding;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type { Downloader, GameStatus } from "@shared";
|
import type { Downloader } from "@shared";
|
||||||
|
import type { Aria2Status } from "aria2";
|
||||||
|
|
||||||
export type GameShop = "steam" | "epic";
|
export type GameShop = "steam" | "epic";
|
||||||
export type CatalogueCategory = "recently_added" | "trending";
|
export type CatalogueCategory = "recently_added" | "trending";
|
||||||
@ -91,14 +92,12 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
status: GameStatus | null;
|
status: Aria2Status | null;
|
||||||
folderName: string;
|
folderName: string;
|
||||||
downloadPath: string | null;
|
downloadPath: string | null;
|
||||||
repacks: GameRepack[];
|
repacks: GameRepack[];
|
||||||
repack: GameRepack | null;
|
repack: GameRepack | null;
|
||||||
progress: number;
|
progress: number;
|
||||||
fileVerificationProgress: number;
|
|
||||||
decompressionProgress: number;
|
|
||||||
bytesDownloaded: number;
|
bytesDownloaded: number;
|
||||||
playTimeInMilliseconds: number;
|
playTimeInMilliseconds: number;
|
||||||
downloader: Downloader;
|
downloader: Downloader;
|
||||||
@ -109,11 +108,15 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TorrentProgress {
|
export interface DownloadProgress {
|
||||||
downloadSpeed: number;
|
downloadSpeed: number;
|
||||||
timeRemaining: number;
|
timeRemaining: number;
|
||||||
numPeers: number;
|
numPeers: number;
|
||||||
numSeeds: number;
|
numSeeds: number;
|
||||||
|
downloadingMetadata: boolean;
|
||||||
|
progress: number;
|
||||||
|
bytesDownloaded: number;
|
||||||
|
fileSize: number;
|
||||||
game: Omit<Game, "repacks">;
|
game: Omit<Game, "repacks">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import platform
|
|
||||||
|
|
||||||
class Fifo:
|
|
||||||
socket_handle = None
|
|
||||||
|
|
||||||
def __init__(self, path: str):
|
|
||||||
if platform.system() == "Windows":
|
|
||||||
import win32file
|
|
||||||
|
|
||||||
self.socket_handle = win32file.CreateFile(path, win32file.GENERIC_READ | win32file.GENERIC_WRITE,
|
|
||||||
0, None, win32file.OPEN_EXISTING, win32file.FILE_ATTRIBUTE_NORMAL, None)
|
|
||||||
else:
|
|
||||||
import socket
|
|
||||||
self.socket_handle = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
self.socket_handle.connect(path)
|
|
||||||
|
|
||||||
def recv(self, bufSize: int):
|
|
||||||
if platform.system() == "Windows":
|
|
||||||
import win32file
|
|
||||||
|
|
||||||
result, data = win32file.ReadFile(self.socket_handle, bufSize)
|
|
||||||
return data
|
|
||||||
else:
|
|
||||||
return self.socket_handle.recv(bufSize)
|
|
||||||
|
|
||||||
def send_message(self, msg: str):
|
|
||||||
buffer = bytearray(1024 * 2)
|
|
||||||
buffer[:len(msg)] = bytes(msg, "utf-8")
|
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
|
||||||
import win32file
|
|
||||||
|
|
||||||
win32file.WriteFile(self.socket_handle, buffer)
|
|
||||||
else:
|
|
||||||
self.socket_handle.send(buffer)
|
|
@ -1,103 +0,0 @@
|
|||||||
import libtorrent as lt
|
|
||||||
import sys
|
|
||||||
from fifo import Fifo
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
torrent_port = sys.argv[1]
|
|
||||||
read_sock_path = sys.argv[2]
|
|
||||||
write_sock_path = sys.argv[3]
|
|
||||||
|
|
||||||
session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)})
|
|
||||||
read_fifo = Fifo(read_sock_path)
|
|
||||||
write_fifo = Fifo(write_sock_path)
|
|
||||||
|
|
||||||
torrent_handle = None
|
|
||||||
downloading_game_id = 0
|
|
||||||
|
|
||||||
def get_eta(status):
|
|
||||||
remaining_bytes = status.total_wanted - status.total_wanted_done
|
|
||||||
|
|
||||||
if remaining_bytes >= 0 and status.download_rate > 0:
|
|
||||||
return (remaining_bytes / status.download_rate) * 1000
|
|
||||||
else:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def start_download(game_id: int, magnet: str, save_path: str):
|
|
||||||
global torrent_handle
|
|
||||||
global downloading_game_id
|
|
||||||
|
|
||||||
params = {'url': magnet, 'save_path': save_path}
|
|
||||||
torrent_handle = session.add_torrent(params)
|
|
||||||
downloading_game_id = game_id
|
|
||||||
torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
|
||||||
torrent_handle.resume()
|
|
||||||
|
|
||||||
def pause_download():
|
|
||||||
global downloading_game_id
|
|
||||||
|
|
||||||
if torrent_handle:
|
|
||||||
torrent_handle.pause()
|
|
||||||
torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
|
||||||
downloading_game_id = 0
|
|
||||||
|
|
||||||
def cancel_download():
|
|
||||||
global downloading_game_id
|
|
||||||
global torrent_handle
|
|
||||||
|
|
||||||
if torrent_handle:
|
|
||||||
torrent_handle.pause()
|
|
||||||
session.remove_torrent(torrent_handle)
|
|
||||||
torrent_handle = None
|
|
||||||
downloading_game_id = 0
|
|
||||||
|
|
||||||
def get_download_updates():
|
|
||||||
while True:
|
|
||||||
if downloading_game_id == 0:
|
|
||||||
time.sleep(0.5)
|
|
||||||
continue
|
|
||||||
|
|
||||||
status = torrent_handle.status()
|
|
||||||
info = torrent_handle.get_torrent_info()
|
|
||||||
|
|
||||||
write_fifo.send_message(json.dumps({
|
|
||||||
'folderName': info.name() if info else "",
|
|
||||||
'fileSize': info.total_size() if info else 0,
|
|
||||||
'gameId': downloading_game_id,
|
|
||||||
'progress': status.progress,
|
|
||||||
'downloadSpeed': status.download_rate,
|
|
||||||
'timeRemaining': get_eta(status),
|
|
||||||
'numPeers': status.num_peers,
|
|
||||||
'numSeeds': status.num_seeds,
|
|
||||||
'status': status.state,
|
|
||||||
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if status.progress == 1:
|
|
||||||
cancel_download()
|
|
||||||
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
def listen_to_socket():
|
|
||||||
while True:
|
|
||||||
msg = read_fifo.recv(1024 * 2)
|
|
||||||
payload = json.loads(msg.decode("utf-8"))
|
|
||||||
|
|
||||||
if payload['action'] == "start":
|
|
||||||
start_download(payload['game_id'], payload['magnet'], payload['save_path'])
|
|
||||||
continue
|
|
||||||
|
|
||||||
if payload['action'] == "pause":
|
|
||||||
pause_download()
|
|
||||||
continue
|
|
||||||
|
|
||||||
if payload['action'] == "cancel":
|
|
||||||
cancel_download()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
p1 = threading.Thread(target=get_download_updates)
|
|
||||||
p2 = threading.Thread(target=listen_to_socket)
|
|
||||||
|
|
||||||
p1.start()
|
|
||||||
p2.start()
|
|
@ -1,20 +0,0 @@
|
|||||||
from cx_Freeze import setup, Executable
|
|
||||||
|
|
||||||
# Dependencies are automatically detected, but it might need fine tuning.
|
|
||||||
build_exe_options = {
|
|
||||||
"packages": ["libtorrent"],
|
|
||||||
"build_exe": "hydra-download-manager",
|
|
||||||
"include_msvcr": True
|
|
||||||
}
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="hydra-download-manager",
|
|
||||||
version="0.1",
|
|
||||||
description="Hydra Torrent Client",
|
|
||||||
options={"build_exe": build_exe_options},
|
|
||||||
executables=[Executable(
|
|
||||||
"torrent-client/main.py",
|
|
||||||
target_name="hydra-download-manager",
|
|
||||||
icon="build/icon.ico"
|
|
||||||
)]
|
|
||||||
)
|
|
15
yarn.lock
15
yarn.lock
@ -1742,6 +1742,14 @@ aria-query@^5.3.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal "^2.0.3"
|
dequal "^2.0.3"
|
||||||
|
|
||||||
|
aria2@^4.1.2:
|
||||||
|
version "4.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/aria2/-/aria2-4.1.2.tgz#0ecbc50beea82856c88b4de71dac336154f67362"
|
||||||
|
integrity sha512-qTBr2RY8RZQmiUmbj2KXFvkErNxU4aTHZszszzwhE8svy2PEVX+IYR/c4Rp9Tuw4QkeU8cylGy6McV6Yl8i7Qw==
|
||||||
|
dependencies:
|
||||||
|
node-fetch "^2.6.1"
|
||||||
|
ws "^7.4.0"
|
||||||
|
|
||||||
array-buffer-byte-length@^1.0.1:
|
array-buffer-byte-length@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
|
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
|
||||||
@ -4685,7 +4693,7 @@ node-domexception@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
|
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
|
||||||
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
|
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
|
||||||
|
|
||||||
node-fetch@^2.6.7:
|
node-fetch@^2.6.1, node-fetch@^2.6.7:
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||||
@ -6356,6 +6364,11 @@ wrappy@1:
|
|||||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||||
|
|
||||||
|
ws@^7.4.0:
|
||||||
|
version "7.5.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
|
||||||
|
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
|
||||||
|
|
||||||
ws@^8.16.0:
|
ws@^8.16.0:
|
||||||
version "8.17.0"
|
version "8.17.0"
|
||||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"
|
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"
|
||||||
|
Loading…
Reference in New Issue
Block a user