mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	feat: adding aria2
This commit is contained in:
		
							parent
							
								
									a89e6760da
								
							
						
					
					
						commit
						4941709296
					
				
					 58 changed files with 895 additions and 1329 deletions
				
			
		
							
								
								
									
										11
									
								
								.github/workflows/build.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/build.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -22,17 +22,6 @@ jobs:
 | 
			
		|||
      - name: Install dependencies
 | 
			
		||||
        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
 | 
			
		||||
        if: matrix.os == 'ubuntu-latest'
 | 
			
		||||
        run: yarn build:linux
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										11
									
								
								.github/workflows/release.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/release.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -24,17 +24,6 @@ jobs:
 | 
			
		|||
      - name: Install dependencies
 | 
			
		||||
        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
 | 
			
		||||
        if: matrix.os == 'ubuntu-latest'
 | 
			
		||||
        run: yarn build:linux
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
.vscode
 | 
			
		||||
node_modules
 | 
			
		||||
hydra-download-manager
 | 
			
		||||
aria2*
 | 
			
		||||
fastlist.exe
 | 
			
		||||
__pycache__
 | 
			
		||||
dist
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,6 @@ productName: Hydra
 | 
			
		|||
directories:
 | 
			
		||||
  buildResources: build
 | 
			
		||||
extraResources:
 | 
			
		||||
  - hydra-download-manager
 | 
			
		||||
  - hydra.db
 | 
			
		||||
  - fastlist.exe
 | 
			
		||||
  - seeds
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								hydra.db
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								hydra.db
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -41,6 +41,7 @@
 | 
			
		|||
    "@reduxjs/toolkit": "^2.2.3",
 | 
			
		||||
    "@vanilla-extract/css": "^1.14.2",
 | 
			
		||||
    "@vanilla-extract/recipes": "^0.5.2",
 | 
			
		||||
    "aria2": "^4.1.2",
 | 
			
		||||
    "auto-launch": "^5.0.6",
 | 
			
		||||
    "axios": "^1.6.8",
 | 
			
		||||
    "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 type { GameShop } from "@types";
 | 
			
		||||
import { Downloader, GameStatus } from "@shared";
 | 
			
		||||
import { Downloader } from "@shared";
 | 
			
		||||
import type { Aria2Status } from "aria2";
 | 
			
		||||
 | 
			
		||||
@Entity("game")
 | 
			
		||||
export class Game {
 | 
			
		||||
| 
						 | 
				
			
			@ -42,7 +43,7 @@ export class Game {
 | 
			
		|||
  shop: GameShop;
 | 
			
		||||
 | 
			
		||||
  @Column("text", { nullable: true })
 | 
			
		||||
  status: GameStatus | null;
 | 
			
		||||
  status: Aria2Status | null;
 | 
			
		||||
 | 
			
		||||
  @Column("int", { default: Downloader.Torrent })
 | 
			
		||||
  downloader: Downloader;
 | 
			
		||||
| 
						 | 
				
			
			@ -53,9 +54,6 @@ export class Game {
 | 
			
		|||
  @Column("float", { default: 0 })
 | 
			
		||||
  progress: number;
 | 
			
		||||
 | 
			
		||||
  @Column("float", { default: 0 })
 | 
			
		||||
  fileVerificationProgress: number;
 | 
			
		||||
 | 
			
		||||
  @Column("int", { default: 0 })
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
import path from "node:path";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +14,7 @@ const deleteGameFolder = async (
 | 
			
		|||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: gameId,
 | 
			
		||||
      status: GameStatus.Cancelled,
 | 
			
		||||
      status: "removed",
 | 
			
		||||
      isDeleted: false,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,6 @@ import { gameRepository } from "@main/repository";
 | 
			
		|||
 | 
			
		||||
import { searchRepacks } from "../helpers/search-games";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
import { sortBy } from "lodash-es";
 | 
			
		||||
 | 
			
		||||
const getLibrary = async () =>
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +23,7 @@ const getLibrary = async () =>
 | 
			
		|||
          ...game,
 | 
			
		||||
          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 { gameRepository } from "../../repository";
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
 | 
			
		||||
const removeGame = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +8,7 @@ const removeGame = async (
 | 
			
		|||
  await gameRepository.update(
 | 
			
		||||
    {
 | 
			
		||||
      id: gameId,
 | 
			
		||||
      status: GameStatus.Cancelled,
 | 
			
		||||
      status: "removed",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      status: null,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,53 +1,25 @@
 | 
			
		|||
import { gameRepository } from "@main/repository";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { WindowManager } from "@main/services";
 | 
			
		||||
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
import { DownloadManager } from "@main/services";
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
 | 
			
		||||
const cancelGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
  await DownloadManager.cancelDownload(gameId);
 | 
			
		||||
 | 
			
		||||
  await gameRepository.update(
 | 
			
		||||
    {
 | 
			
		||||
      id: gameId,
 | 
			
		||||
      isDeleted: false,
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
        GameStatus.Paused,
 | 
			
		||||
        GameStatus.Seeding,
 | 
			
		||||
        GameStatus.Finished,
 | 
			
		||||
      ]),
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!game) return;
 | 
			
		||||
  DownloadManager.cancelDownload();
 | 
			
		||||
 | 
			
		||||
  await gameRepository
 | 
			
		||||
    .update(
 | 
			
		||||
      {
 | 
			
		||||
        id: game.id,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
        status: GameStatus.Cancelled,
 | 
			
		||||
      status: "removed",
 | 
			
		||||
      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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,30 +1,13 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { gameRepository } from "../../repository";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
import { DownloadManager, WindowManager } from "@main/services";
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
import { DownloadManager } from "@main/services";
 | 
			
		||||
 | 
			
		||||
const pauseGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  gameId: number
 | 
			
		||||
) => {
 | 
			
		||||
  DownloadManager.pauseDownload();
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
    });
 | 
			
		||||
  await DownloadManager.pauseDownload();
 | 
			
		||||
  await gameRepository.update({ id: gameId }, { status: "paused" });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent("pauseGameDownload", pauseGameDownload);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,7 @@
 | 
			
		|||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { gameRepository } from "../../repository";
 | 
			
		||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
 | 
			
		||||
import { DownloadManager } from "@main/services";
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
 | 
			
		||||
const resumeGameDownload = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
| 
						 | 
				
			
			@ -18,31 +16,13 @@ const resumeGameDownload = async (
 | 
			
		|||
  });
 | 
			
		||||
 | 
			
		||||
  if (!game) return;
 | 
			
		||||
  DownloadManager.pauseDownload();
 | 
			
		||||
 | 
			
		||||
  if (game.status === GameStatus.Paused) {
 | 
			
		||||
    const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
 | 
			
		||||
  if (game.status === "paused") {
 | 
			
		||||
    await DownloadManager.pauseDownload();
 | 
			
		||||
 | 
			
		||||
    DownloadManager.resumeDownload(gameId);
 | 
			
		||||
    await gameRepository.update({ status: "active" }, { status: "paused" });
 | 
			
		||||
 | 
			
		||||
    await gameRepository.update(
 | 
			
		||||
      {
 | 
			
		||||
        status: In([
 | 
			
		||||
          GameStatus.Downloading,
 | 
			
		||||
          GameStatus.DownloadingMetadata,
 | 
			
		||||
          GameStatus.CheckingFiles,
 | 
			
		||||
        ]),
 | 
			
		||||
      },
 | 
			
		||||
      { status: GameStatus.Paused }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    await gameRepository.update(
 | 
			
		||||
      { id: game.id },
 | 
			
		||||
      {
 | 
			
		||||
        status: GameStatus.Downloading,
 | 
			
		||||
        downloadPath: downloadsPath,
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    await DownloadManager.resumeDownload(gameId);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,9 +8,8 @@ import { registerEvent } from "../register-event";
 | 
			
		|||
 | 
			
		||||
import type { GameShop } from "@types";
 | 
			
		||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
import { DownloadManager } from "@main/services";
 | 
			
		||||
import { Downloader, GameStatus } from "@shared";
 | 
			
		||||
import { Downloader } from "@shared";
 | 
			
		||||
import { stateManager } from "@main/state-manager";
 | 
			
		||||
 | 
			
		||||
const startGameDownload = async (
 | 
			
		||||
| 
						 | 
				
			
			@ -42,19 +41,9 @@ const startGameDownload = async (
 | 
			
		|||
    }),
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  if (!repack || game?.status === GameStatus.Downloading) return;
 | 
			
		||||
  DownloadManager.pauseDownload();
 | 
			
		||||
  if (!repack || game?.status === "active") return;
 | 
			
		||||
 | 
			
		||||
  await gameRepository.update(
 | 
			
		||||
    {
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
      ]),
 | 
			
		||||
    },
 | 
			
		||||
    { status: GameStatus.Paused }
 | 
			
		||||
  );
 | 
			
		||||
  await gameRepository.update({ status: "active" }, { status: "paused" });
 | 
			
		||||
 | 
			
		||||
  if (game) {
 | 
			
		||||
    await gameRepository.update(
 | 
			
		||||
| 
						 | 
				
			
			@ -62,17 +51,17 @@ const startGameDownload = async (
 | 
			
		|||
        id: game.id,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        status: GameStatus.DownloadingMetadata,
 | 
			
		||||
        downloadPath: downloadPath,
 | 
			
		||||
        status: "active",
 | 
			
		||||
        downloadPath,
 | 
			
		||||
        downloader,
 | 
			
		||||
        repack: { id: repackId },
 | 
			
		||||
        isDeleted: false,
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    DownloadManager.downloadGame(game.id);
 | 
			
		||||
    await DownloadManager.startDownload(game.id);
 | 
			
		||||
 | 
			
		||||
    game.status = GameStatus.DownloadingMetadata;
 | 
			
		||||
    game.status = "active";
 | 
			
		||||
 | 
			
		||||
    return game;
 | 
			
		||||
  } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +80,7 @@ const startGameDownload = async (
 | 
			
		|||
        objectID,
 | 
			
		||||
        downloader,
 | 
			
		||||
        shop: gameShop,
 | 
			
		||||
        status: GameStatus.Downloading,
 | 
			
		||||
        status: "active",
 | 
			
		||||
        downloadPath,
 | 
			
		||||
        repack: { id: repackId },
 | 
			
		||||
      })
 | 
			
		||||
| 
						 | 
				
			
			@ -105,7 +94,7 @@ const startGameDownload = async (
 | 
			
		|||
        return result;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
    DownloadManager.downloadGame(createdGame.id);
 | 
			
		||||
    DownloadManager.startDownload(createdGame.id);
 | 
			
		||||
 | 
			
		||||
    const { repack: _, ...rest } = createdGame;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,17 +13,15 @@ import {
 | 
			
		|||
  repackRepository,
 | 
			
		||||
  userPreferencesRepository,
 | 
			
		||||
} from "./repository";
 | 
			
		||||
import { TorrentDownloader } from "./services";
 | 
			
		||||
import { Repack, UserPreferences } from "./entity";
 | 
			
		||||
import { Notification } from "electron";
 | 
			
		||||
import { t } from "i18next";
 | 
			
		||||
import { GameStatus } from "@shared";
 | 
			
		||||
import { In } from "typeorm";
 | 
			
		||||
import fs from "node:fs";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
import { RealDebridClient } from "./services/real-debrid";
 | 
			
		||||
import { orderBy } from "lodash-es";
 | 
			
		||||
import { SteamGame } from "@types";
 | 
			
		||||
import { Not } from "typeorm";
 | 
			
		||||
 | 
			
		||||
startProcessWatcher();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -72,7 +70,7 @@ const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
const loadState = async (userPreferences: UserPreferences | null) => {
 | 
			
		||||
  const repacks = await repackRepository.find({
 | 
			
		||||
  const repacks = repackRepository.find({
 | 
			
		||||
    order: {
 | 
			
		||||
      createdAt: "desc",
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -82,7 +80,7 @@ const loadState = async (userPreferences: UserPreferences | null) => {
 | 
			
		|||
    fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8")
 | 
			
		||||
  ) as SteamGame[];
 | 
			
		||||
 | 
			
		||||
  stateManager.setValue("repacks", repacks);
 | 
			
		||||
  stateManager.setValue("repacks", await repacks);
 | 
			
		||||
  stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc"));
 | 
			
		||||
 | 
			
		||||
  import("./events");
 | 
			
		||||
| 
						 | 
				
			
			@ -90,22 +88,19 @@ const loadState = async (userPreferences: UserPreferences | null) => {
 | 
			
		|||
  if (userPreferences?.realDebridApiToken)
 | 
			
		||||
    await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
 | 
			
		||||
 | 
			
		||||
  await DownloadManager.connect();
 | 
			
		||||
 | 
			
		||||
  const game = await gameRepository.findOne({
 | 
			
		||||
    where: {
 | 
			
		||||
      status: In([
 | 
			
		||||
        GameStatus.Downloading,
 | 
			
		||||
        GameStatus.DownloadingMetadata,
 | 
			
		||||
        GameStatus.CheckingFiles,
 | 
			
		||||
      ]),
 | 
			
		||||
      status: "active",
 | 
			
		||||
      progress: Not(1),
 | 
			
		||||
      isDeleted: false,
 | 
			
		||||
    },
 | 
			
		||||
    relations: { repack: true },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await TorrentDownloader.startClient();
 | 
			
		||||
 | 
			
		||||
  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 { writePipe } from "./fifo";
 | 
			
		||||
import { RealDebridDownloader } from "./downloaders";
 | 
			
		||||
import { DownloadProgress } from "@types";
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
    return gameRepository.findOne({
 | 
			
		||||
| 
						 | 
				
			
			@ -18,59 +161,80 @@ export class DownloadManager {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static async cancelDownload() {
 | 
			
		||||
    if (
 | 
			
		||||
      this.gameDownloading &&
 | 
			
		||||
      this.gameDownloading.downloader === Downloader.Torrent
 | 
			
		||||
    ) {
 | 
			
		||||
      writePipe.write({ action: "cancel" });
 | 
			
		||||
  private static clearCurrentDownload() {
 | 
			
		||||
    if (this.gameId) {
 | 
			
		||||
      this.downloads.delete(this.gameId);
 | 
			
		||||
      this.gid = null;
 | 
			
		||||
      this.gameId = null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
      RealDebridDownloader.destroy();
 | 
			
		||||
        this.downloads.delete(gameId);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static async pauseDownload() {
 | 
			
		||||
    if (
 | 
			
		||||
      this.gameDownloading &&
 | 
			
		||||
      this.gameDownloading.downloader === Downloader.Torrent
 | 
			
		||||
    ) {
 | 
			
		||||
      writePipe.write({ action: "pause" });
 | 
			
		||||
    } else {
 | 
			
		||||
      RealDebridDownloader.destroy();
 | 
			
		||||
    if (this.gid) {
 | 
			
		||||
      await this.aria2.call("forcePause", this.gid);
 | 
			
		||||
      this.gid = null;
 | 
			
		||||
      this.gameId = null;
 | 
			
		||||
 | 
			
		||||
      WindowManager.mainWindow?.setProgressBar(-1);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static async resumeDownload(gameId: number) {
 | 
			
		||||
    const game = await this.getGame(gameId);
 | 
			
		||||
    await this.aria2.call("forcePauseAll");
 | 
			
		||||
 | 
			
		||||
    if (game!.downloader === Downloader.Torrent) {
 | 
			
		||||
      writePipe.write({
 | 
			
		||||
        action: "start",
 | 
			
		||||
        game_id: game!.id,
 | 
			
		||||
        magnet: game!.repack.magnet,
 | 
			
		||||
        save_path: game!.downloadPath,
 | 
			
		||||
      });
 | 
			
		||||
    if (this.downloads.has(gameId)) {
 | 
			
		||||
      const gid = this.downloads.get(gameId)!;
 | 
			
		||||
      await this.aria2.call("unpause", gid);
 | 
			
		||||
 | 
			
		||||
      this.gid = gid;
 | 
			
		||||
      this.gameId = gameId;
 | 
			
		||||
    } else {
 | 
			
		||||
      RealDebridDownloader.startDownload(game!);
 | 
			
		||||
      return this.startDownload(gameId);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    this.gameDownloading = game!;
 | 
			
		||||
  }
 | 
			
		||||
  static async startDownload(gameId: number) {
 | 
			
		||||
    await this.aria2.call("forcePauseAll");
 | 
			
		||||
 | 
			
		||||
  static async downloadGame(gameId: number) {
 | 
			
		||||
    const game = await this.getGame(gameId);
 | 
			
		||||
    const game = await this.getGame(gameId)!;
 | 
			
		||||
 | 
			
		||||
    if (game!.downloader === Downloader.Torrent) {
 | 
			
		||||
      writePipe.write({
 | 
			
		||||
        action: "start",
 | 
			
		||||
        game_id: game!.id,
 | 
			
		||||
        magnet: game!.repack.magnet,
 | 
			
		||||
        save_path: game!.downloadPath,
 | 
			
		||||
      });
 | 
			
		||||
    if (game) {
 | 
			
		||||
      const options = {
 | 
			
		||||
        dir: game.downloadPath!,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      if (game.downloader === Downloader.RealDebrid) {
 | 
			
		||||
        const downloadUrl = decodeURIComponent(
 | 
			
		||||
          await RealDebridClient.getDownloadUrl(game)
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        this.gid = await this.aria2.call("addUri", [downloadUrl], options);
 | 
			
		||||
      } else {
 | 
			
		||||
      RealDebridDownloader.startDownload(game!);
 | 
			
		||||
        this.gid = await this.aria2.call(
 | 
			
		||||
          "addUri",
 | 
			
		||||
          [game.repack.magnet],
 | 
			
		||||
          options
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    this.gameDownloading = game!;
 | 
			
		||||
      this.gameId = gameId;
 | 
			
		||||
      this.downloads.set(gameId, this.gid);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 "./update-resolver";
 | 
			
		||||
export * from "./window-manager";
 | 
			
		||||
export * from "./fifo";
 | 
			
		||||
export * from "./downloaders";
 | 
			
		||||
export * from "./download-manager";
 | 
			
		||||
export * from "./how-long-to-beat";
 | 
			
		||||
export * from "./process-watcher";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer } from "electron";
 | 
			
		|||
import type {
 | 
			
		||||
  CatalogueCategory,
 | 
			
		||||
  GameShop,
 | 
			
		||||
  TorrentProgress,
 | 
			
		||||
  DownloadProgress,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
} from "@types";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -32,10 +32,10 @@ contextBridge.exposeInMainWorld("electron", {
 | 
			
		|||
    ipcRenderer.invoke("pauseGameDownload", gameId),
 | 
			
		||||
  resumeGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("resumeGameDownload", gameId),
 | 
			
		||||
  onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
 | 
			
		||||
  onDownloadProgress: (cb: (value: DownloadProgress) => void) => {
 | 
			
		||||
    const listener = (
 | 
			
		||||
      _event: Electron.IpcRendererEvent,
 | 
			
		||||
      value: TorrentProgress
 | 
			
		||||
      value: DownloadProgress
 | 
			
		||||
    ) => cb(value);
 | 
			
		||||
    ipcRenderer.on("on-download-progress", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-download-progress", listener);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,7 +19,6 @@ import {
 | 
			
		|||
  setUserPreferences,
 | 
			
		||||
  toggleDraggingDisabled,
 | 
			
		||||
} from "@renderer/features";
 | 
			
		||||
import { GameStatusHelper } from "@shared";
 | 
			
		||||
 | 
			
		||||
document.body.classList.add(themeClass);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -54,7 +53,7 @@ export function App({ children }: AppProps) {
 | 
			
		|||
  useEffect(() => {
 | 
			
		||||
    const unsubscribe = window.electron.onDownloadProgress(
 | 
			
		||||
      (downloadProgress) => {
 | 
			
		||||
        if (GameStatusHelper.isReady(downloadProgress.game.status)) {
 | 
			
		||||
        if (downloadProgress.game.progress === 1) {
 | 
			
		||||
          clearDownload();
 | 
			
		||||
          updateLibrary();
 | 
			
		||||
          return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,5 +43,11 @@ export const backdrop = recipe({
 | 
			
		|||
        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) {
 | 
			
		||||
  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 { useNavigate } from "react-router-dom";
 | 
			
		||||
import { VERSION_CODENAME } from "@renderer/constants";
 | 
			
		||||
import { GameStatus, GameStatusHelper } from "@shared";
 | 
			
		||||
 | 
			
		||||
export function BottomPanel() {
 | 
			
		||||
  const { t } = useTranslation("bottom_panel");
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const { game, progress, downloadSpeed, eta } = useDownload();
 | 
			
		||||
  const { lastPacket, progress, downloadSpeed, eta } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const isGameDownloading =
 | 
			
		||||
    game && GameStatusHelper.isDownloading(game.status ?? null);
 | 
			
		||||
    lastPacket?.game && lastPacket?.game.status === "active";
 | 
			
		||||
 | 
			
		||||
  const [version, setVersion] = useState("");
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -27,17 +26,8 @@ export function BottomPanel() {
 | 
			
		|||
 | 
			
		||||
  const status = useMemo(() => {
 | 
			
		||||
    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", {
 | 
			
		||||
        title: game?.title,
 | 
			
		||||
        title: lastPacket?.game.title,
 | 
			
		||||
        percentage: progress,
 | 
			
		||||
        eta,
 | 
			
		||||
        speed: downloadSpeed,
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +35,7 @@ export function BottomPanel() {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    return t("no_downloads_in_progress");
 | 
			
		||||
  }, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
 | 
			
		||||
  }, [t, isGameDownloading, lastPacket?.game, progress, eta, downloadSpeed]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <footer
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,6 @@ import { useDownload, useLibrary } from "@renderer/hooks";
 | 
			
		|||
import { routes } from "./routes";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./sidebar.css";
 | 
			
		||||
import { GameStatus, GameStatusHelper } from "@shared";
 | 
			
		||||
import { buildGameDetailsPath } from "@renderer/helpers";
 | 
			
		||||
 | 
			
		||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
 | 
			
		||||
| 
						 | 
				
			
			@ -35,14 +34,14 @@ export function Sidebar() {
 | 
			
		|||
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
 | 
			
		||||
  const { game: gameDownloading, progress } = useDownload();
 | 
			
		||||
  const { lastPacket, progress } = useDownload();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    updateLibrary();
 | 
			
		||||
  }, [gameDownloading?.id, updateLibrary]);
 | 
			
		||||
  }, [lastPacket?.game.id, updateLibrary]);
 | 
			
		||||
 | 
			
		||||
  const isDownloading = library.some((game) =>
 | 
			
		||||
    GameStatusHelper.isDownloading(game.status)
 | 
			
		||||
  const isDownloading = library.some(
 | 
			
		||||
    (game) => game.status === "active" && game.progress !== 1
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const sidebarRef = useRef<HTMLElement>(null);
 | 
			
		||||
| 
						 | 
				
			
			@ -101,18 +100,9 @@ export function Sidebar() {
 | 
			
		|||
  }, [isResizing]);
 | 
			
		||||
 | 
			
		||||
  const getGameTitle = (game: Game) => {
 | 
			
		||||
    if (game.status === GameStatus.Paused)
 | 
			
		||||
      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 (game.status === "paused") return t("paused", { title: game.title });
 | 
			
		||||
 | 
			
		||||
    if (lastPacket?.game.id === game.id) {
 | 
			
		||||
      return t("downloading", {
 | 
			
		||||
        title: game.title,
 | 
			
		||||
        percentage: progress,
 | 
			
		||||
| 
						 | 
				
			
			@ -183,7 +173,7 @@ export function Sidebar() {
 | 
			
		|||
                className={styles.menuItem({
 | 
			
		||||
                  active:
 | 
			
		||||
                    location.pathname === `/game/${game.shop}/${game.objectID}`,
 | 
			
		||||
                  muted: game.status === GameStatus.Cancelled,
 | 
			
		||||
                  muted: game.status === "removed",
 | 
			
		||||
                })}
 | 
			
		||||
              >
 | 
			
		||||
                <button
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										4
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -7,7 +7,7 @@ import type {
 | 
			
		|||
  HowLongToBeatCategory,
 | 
			
		||||
  ShopDetails,
 | 
			
		||||
  Steam250Game,
 | 
			
		||||
  TorrentProgress,
 | 
			
		||||
  DownloadProgress,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import type { DiskSpace } from "check-disk-space";
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +31,7 @@ declare global {
 | 
			
		|||
    pauseGameDownload: (gameId: number) => Promise<void>;
 | 
			
		||||
    resumeGameDownload: (gameId: number) => Promise<void>;
 | 
			
		||||
    onDownloadProgress: (
 | 
			
		||||
      cb: (value: TorrentProgress) => void
 | 
			
		||||
      cb: (value: DownloadProgress) => void
 | 
			
		||||
    ) => () => Electron.IpcRenderer;
 | 
			
		||||
 | 
			
		||||
    /* Catalogue */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,9 @@
 | 
			
		|||
import { createSlice } from "@reduxjs/toolkit";
 | 
			
		||||
import type { PayloadAction } from "@reduxjs/toolkit";
 | 
			
		||||
import type { TorrentProgress } from "@types";
 | 
			
		||||
import type { DownloadProgress } from "@types";
 | 
			
		||||
 | 
			
		||||
export interface DownloadState {
 | 
			
		||||
  lastPacket: TorrentProgress | null;
 | 
			
		||||
  lastPacket: DownloadProgress | null;
 | 
			
		||||
  gameId: number | null;
 | 
			
		||||
  gamesWithDeletionInProgress: number[];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -18,7 +18,7 @@ export const downloadSlice = createSlice({
 | 
			
		|||
  name: "download",
 | 
			
		||||
  initialState,
 | 
			
		||||
  reducers: {
 | 
			
		||||
    setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
 | 
			
		||||
    setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
 | 
			
		||||
      state.lastPacket = action.payload;
 | 
			
		||||
      if (!state.gameId) state.gameId = action.payload.game.id;
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,9 +9,9 @@ import {
 | 
			
		|||
  setGameDeleting,
 | 
			
		||||
  removeGameFromDeleting,
 | 
			
		||||
} from "@renderer/features";
 | 
			
		||||
import type { GameShop, TorrentProgress } from "@types";
 | 
			
		||||
import type { DownloadProgress, GameShop } from "@types";
 | 
			
		||||
import { useDate } from "./use-date";
 | 
			
		||||
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
 | 
			
		||||
import { formatBytes } from "@shared";
 | 
			
		||||
 | 
			
		||||
export function useDownload() {
 | 
			
		||||
  const { updateLibrary } = useLibrary();
 | 
			
		||||
| 
						 | 
				
			
			@ -38,16 +38,16 @@ export function useDownload() {
 | 
			
		|||
        return game;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
  const pauseDownload = (gameId: number) =>
 | 
			
		||||
    window.electron.pauseGameDownload(gameId).then(() => {
 | 
			
		||||
  const pauseDownload = async (gameId: number) => {
 | 
			
		||||
    await window.electron.pauseGameDownload(gameId);
 | 
			
		||||
    await updateLibrary();
 | 
			
		||||
    dispatch(clearDownload());
 | 
			
		||||
      updateLibrary();
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const resumeDownload = (gameId: number) =>
 | 
			
		||||
    window.electron.resumeGameDownload(gameId).then(() => {
 | 
			
		||||
      updateLibrary();
 | 
			
		||||
    });
 | 
			
		||||
  const resumeDownload = async (gameId: number) => {
 | 
			
		||||
    await window.electron.resumeGameDownload(gameId);
 | 
			
		||||
    return updateLibrary();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const cancelDownload = (gameId: number) =>
 | 
			
		||||
    window.electron.cancelGameDownload(gameId).then(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -61,14 +61,8 @@ export function useDownload() {
 | 
			
		|||
      updateLibrary();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  const isVerifying = GameStatusHelper.isVerifying(
 | 
			
		||||
    lastPacket?.game.status ?? null
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const getETA = () => {
 | 
			
		||||
    if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
 | 
			
		||||
      return "";
 | 
			
		||||
    }
 | 
			
		||||
    if (lastPacket && lastPacket.timeRemaining < 0) return "";
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      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) =>
 | 
			
		||||
    window.electron
 | 
			
		||||
      .cancelGameDownload(gameId)
 | 
			
		||||
| 
						 | 
				
			
			@ -107,15 +93,9 @@ export function useDownload() {
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    game: lastPacket?.game,
 | 
			
		||||
    bytesDownloaded: lastPacket?.game.bytesDownloaded,
 | 
			
		||||
    fileSize: lastPacket?.game.fileSize,
 | 
			
		||||
    isVerifying,
 | 
			
		||||
    gameId: lastPacket?.game.id,
 | 
			
		||||
    downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
 | 
			
		||||
    progress: getProgress(),
 | 
			
		||||
    numPeers: lastPacket?.numPeers,
 | 
			
		||||
    numSeeds: lastPacket?.numSeeds,
 | 
			
		||||
    progress: formatDownloadProgress(lastPacket?.game.progress ?? 0),
 | 
			
		||||
    lastPacket,
 | 
			
		||||
    eta: getETA(),
 | 
			
		||||
    startDownload,
 | 
			
		||||
    pauseDownload,
 | 
			
		||||
| 
						 | 
				
			
			@ -125,6 +105,7 @@ export function useDownload() {
 | 
			
		|||
    deleteGame,
 | 
			
		||||
    isGameDeleting,
 | 
			
		||||
    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 {
 | 
			
		||||
    game: gameDownloading,
 | 
			
		||||
    progress,
 | 
			
		||||
    numPeers,
 | 
			
		||||
    numSeeds,
 | 
			
		||||
    pauseDownload,
 | 
			
		||||
    resumeDownload,
 | 
			
		||||
    removeGameFromLibrary,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,25 +1,25 @@
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import type { ShopDetails } from "@types";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./game-details.css";
 | 
			
		||||
import { useContext } from "react";
 | 
			
		||||
import { gameDetailsContext } from "./game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface DescriptionHeaderProps {
 | 
			
		||||
  gameDetails: ShopDetails;
 | 
			
		||||
}
 | 
			
		||||
export function DescriptionHeader() {
 | 
			
		||||
  const { shopDetails } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  if (!shopDetails) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.descriptionHeader}>
 | 
			
		||||
      <section className={styles.descriptionHeaderInfo}>
 | 
			
		||||
        <p>
 | 
			
		||||
          {t("release_date", {
 | 
			
		||||
            date: gameDetails?.release_date.date,
 | 
			
		||||
            date: shopDetails?.release_date.date,
 | 
			
		||||
          })}
 | 
			
		||||
        </p>
 | 
			
		||||
        <p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
 | 
			
		||||
        <p>{t("publisher", { publisher: shopDetails.publishers[0] })}</p>
 | 
			
		||||
      </section>
 | 
			
		||||
    </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 type { ShopDetails } from "@types";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./gallery-slider.css";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { gameDetailsContext } from "./game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface GallerySliderProps {
 | 
			
		||||
  gameDetails: ShopDetails;
 | 
			
		||||
}
 | 
			
		||||
export function GallerySlider() {
 | 
			
		||||
  const { shopDetails } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		||||
  const scrollContainerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const mediaContainerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  const hasScreenshots = gameDetails && gameDetails.screenshots.length;
 | 
			
		||||
  const hasMovies = gameDetails && gameDetails.movies?.length;
 | 
			
		||||
  const hasScreenshots = shopDetails && shopDetails.screenshots.length;
 | 
			
		||||
  const hasMovies = shopDetails && shopDetails.movies?.length;
 | 
			
		||||
 | 
			
		||||
  const [mediaCount] = useState<number>(() => {
 | 
			
		||||
    if (gameDetails.screenshots && gameDetails.movies) {
 | 
			
		||||
      return gameDetails.screenshots.length + gameDetails.movies.length;
 | 
			
		||||
    } else if (gameDetails.movies) {
 | 
			
		||||
      return gameDetails.movies.length;
 | 
			
		||||
    } else if (gameDetails.screenshots) {
 | 
			
		||||
      return gameDetails.screenshots.length;
 | 
			
		||||
  const mediaCount = useMemo(() => {
 | 
			
		||||
    if (!shopDetails) return 0;
 | 
			
		||||
 | 
			
		||||
    if (shopDetails.screenshots && shopDetails.movies) {
 | 
			
		||||
      return shopDetails.screenshots.length + shopDetails.movies.length;
 | 
			
		||||
    } else if (shopDetails.movies) {
 | 
			
		||||
      return shopDetails.movies.length;
 | 
			
		||||
    } else if (shopDetails.screenshots) {
 | 
			
		||||
      return shopDetails.screenshots.length;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return 0;
 | 
			
		||||
  });
 | 
			
		||||
  }, [shopDetails]);
 | 
			
		||||
 | 
			
		||||
  const [mediaIndex, setMediaIndex] = useState<number>(0);
 | 
			
		||||
  const [mediaIndex, setMediaIndex] = useState(0);
 | 
			
		||||
  const [showArrows, setShowArrows] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const showNextImage = () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -52,7 +51,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setMediaIndex(0);
 | 
			
		||||
  }, [gameDetails]);
 | 
			
		||||
  }, [shopDetails]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (hasMovies && mediaContainerRef.current) {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,17 +75,17 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
      const scrollLeft = mediaIndex * itemWidth;
 | 
			
		||||
      container.scrollLeft = scrollLeft;
 | 
			
		||||
    }
 | 
			
		||||
  }, [gameDetails, mediaIndex, mediaCount]);
 | 
			
		||||
  }, [shopDetails, mediaIndex, mediaCount]);
 | 
			
		||||
 | 
			
		||||
  const previews = useMemo(() => {
 | 
			
		||||
    const screenshotPreviews =
 | 
			
		||||
      gameDetails?.screenshots.map(({ id, path_thumbnail }) => ({
 | 
			
		||||
      shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
 | 
			
		||||
        id,
 | 
			
		||||
        thumbnail: path_thumbnail,
 | 
			
		||||
      })) ?? [];
 | 
			
		||||
 | 
			
		||||
    if (gameDetails?.movies) {
 | 
			
		||||
      const moviePreviews = gameDetails.movies.map(({ id, thumbnail }) => ({
 | 
			
		||||
    if (shopDetails?.movies) {
 | 
			
		||||
      const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
 | 
			
		||||
        id,
 | 
			
		||||
        thumbnail,
 | 
			
		||||
      }));
 | 
			
		||||
| 
						 | 
				
			
			@ -95,7 +94,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    return screenshotPreviews;
 | 
			
		||||
  }, [gameDetails]);
 | 
			
		||||
  }, [shopDetails]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
| 
						 | 
				
			
			@ -107,8 +106,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
            className={styles.gallerySliderAnimationContainer}
 | 
			
		||||
            ref={mediaContainerRef}
 | 
			
		||||
          >
 | 
			
		||||
            {gameDetails.movies &&
 | 
			
		||||
              gameDetails.movies.map((video) => (
 | 
			
		||||
            {shopDetails.movies &&
 | 
			
		||||
              shopDetails.movies.map((video) => (
 | 
			
		||||
                <video
 | 
			
		||||
                  key={video.id}
 | 
			
		||||
                  controls
 | 
			
		||||
| 
						 | 
				
			
			@ -124,7 +123,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
              ))}
 | 
			
		||||
 | 
			
		||||
            {hasScreenshots &&
 | 
			
		||||
              gameDetails.screenshots.map((image, i) => (
 | 
			
		||||
              shopDetails.screenshots.map((image, i) => (
 | 
			
		||||
                <img
 | 
			
		||||
                  key={image.id}
 | 
			
		||||
                  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 { average } from "color.js";
 | 
			
		||||
import { useCallback, useEffect, useState } from "react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
 | 
			
		||||
import { average } from "color.js";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Steam250Game,
 | 
			
		||||
  type Game,
 | 
			
		||||
  type GameRepack,
 | 
			
		||||
  type GameShop,
 | 
			
		||||
  type ShopDetails,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import { Steam250Game } from "@types";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
import { setHeaderTitle } from "@renderer/features";
 | 
			
		||||
import {
 | 
			
		||||
  buildGameDetailsPath,
 | 
			
		||||
  getSteamLanguage,
 | 
			
		||||
  steamUrlBuilder,
 | 
			
		||||
} from "@renderer/helpers";
 | 
			
		||||
import { useAppDispatch, useDownload } from "@renderer/hooks";
 | 
			
		||||
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
 | 
			
		||||
 | 
			
		||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,153 +16,34 @@ import { DescriptionHeader } from "./description-header";
 | 
			
		|||
import { GameDetailsSkeleton } from "./game-details-skeleton";
 | 
			
		||||
import * as styles from "./game-details.css";
 | 
			
		||||
import { HeroPanel } from "./hero";
 | 
			
		||||
import { RepacksModal } from "./repacks-modal";
 | 
			
		||||
 | 
			
		||||
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 { Sidebar } from "./sidebar/sidebar";
 | 
			
		||||
import {
 | 
			
		||||
  GameDetailsContextConsumer,
 | 
			
		||||
  GameDetailsContextProvider,
 | 
			
		||||
} from "./game-details.context";
 | 
			
		||||
 | 
			
		||||
export function GameDetails() {
 | 
			
		||||
  const { objectID, shop } = useParams();
 | 
			
		||||
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
  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 [isGamePlaying, setIsGamePlaying] = useState(false);
 | 
			
		||||
  const [showInstructionsModal, setShowInstructionsModal] = useState<
 | 
			
		||||
    null | "onlinefix" | "DODI"
 | 
			
		||||
  >(null);
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const { objectID } = useParams();
 | 
			
		||||
  const [searchParams] = useSearchParams();
 | 
			
		||||
 | 
			
		||||
  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 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]);
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    getGame();
 | 
			
		||||
  }, [getGame, gameDownloading?.id]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setGameDetails(null);
 | 
			
		||||
    setGame(null);
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
    setIsGamePlaying(false);
 | 
			
		||||
    dispatch(setHeaderTitle(title));
 | 
			
		||||
 | 
			
		||||
    setRandomGame(null);
 | 
			
		||||
    window.electron.getRandomGame().then((randomGame) => {
 | 
			
		||||
      setRandomGame(randomGame);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    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");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
  }, [objectID]);
 | 
			
		||||
 | 
			
		||||
  const handleRandomizerClick = () => {
 | 
			
		||||
    if (randomGame) {
 | 
			
		||||
| 
						 | 
				
			
			@ -189,32 +57,33 @@ export function GameDetails() {
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
 | 
			
		||||
      <RepacksModal
 | 
			
		||||
        visible={showRepacksModal}
 | 
			
		||||
        repacks={repacks}
 | 
			
		||||
        startDownload={handleStartDownload}
 | 
			
		||||
        onClose={() => setShowRepacksModal(false)}
 | 
			
		||||
      />
 | 
			
		||||
    <GameDetailsContextProvider>
 | 
			
		||||
      <GameDetailsContextConsumer>
 | 
			
		||||
        {({ game, shopDetails, isLoading, setGameColor }) => {
 | 
			
		||||
          const handleHeroLoad = async () => {
 | 
			
		||||
            const output = await average(
 | 
			
		||||
              steamUrlBuilder.libraryHero(objectID!),
 | 
			
		||||
              {
 | 
			
		||||
                amount: 1,
 | 
			
		||||
                format: "hex",
 | 
			
		||||
              }
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
      <OnlineFixInstallationGuide
 | 
			
		||||
        visible={showInstructionsModal === "onlinefix"}
 | 
			
		||||
        onClose={() => setShowInstructionsModal(null)}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <DODIInstallationGuide
 | 
			
		||||
        windowColor={color.light}
 | 
			
		||||
        visible={showInstructionsModal === "DODI"}
 | 
			
		||||
        onClose={() => setShowInstructionsModal(null)}
 | 
			
		||||
      />
 | 
			
		||||
            setGameColor(output as string);
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          return (
 | 
			
		||||
            <SkeletonTheme
 | 
			
		||||
              baseColor={vars.color.background}
 | 
			
		||||
              highlightColor="#444"
 | 
			
		||||
            >
 | 
			
		||||
              {isLoading ? (
 | 
			
		||||
                <GameDetailsSkeleton />
 | 
			
		||||
              ) : (
 | 
			
		||||
                <section className={styles.container}>
 | 
			
		||||
                  <div className={styles.hero}>
 | 
			
		||||
                    <img
 | 
			
		||||
              src={heroImage}
 | 
			
		||||
                      src={steamUrlBuilder.libraryHero(objectID!)}
 | 
			
		||||
                      className={styles.heroImage}
 | 
			
		||||
                      alt={game?.title}
 | 
			
		||||
                      onLoad={handleHeroLoad}
 | 
			
		||||
| 
						 | 
				
			
			@ -230,35 +99,23 @@ export function GameDetails() {
 | 
			
		|||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
 | 
			
		||||
          <HeroPanel
 | 
			
		||||
            game={game}
 | 
			
		||||
            color={color.dark}
 | 
			
		||||
            objectID={objectID!}
 | 
			
		||||
            title={title}
 | 
			
		||||
            repacks={repacks}
 | 
			
		||||
            openRepacksModal={() => setShowRepacksModal(true)}
 | 
			
		||||
            getGame={getGame}
 | 
			
		||||
            isGamePlaying={isGamePlaying}
 | 
			
		||||
          />
 | 
			
		||||
                  <HeroPanel />
 | 
			
		||||
 | 
			
		||||
                  <div className={styles.descriptionContainer}>
 | 
			
		||||
                    <div className={styles.descriptionContent}>
 | 
			
		||||
              {gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
 | 
			
		||||
              {gameDetails && <GallerySlider gameDetails={gameDetails} />}
 | 
			
		||||
                      <DescriptionHeader />
 | 
			
		||||
                      <GallerySlider />
 | 
			
		||||
 | 
			
		||||
                      <div
 | 
			
		||||
                        dangerouslySetInnerHTML={{
 | 
			
		||||
                  __html: gameDetails?.about_the_game ?? t("no_shop_details"),
 | 
			
		||||
                          __html:
 | 
			
		||||
                            shopDetails?.about_the_game ?? t("no_shop_details"),
 | 
			
		||||
                        }}
 | 
			
		||||
                        className={styles.description}
 | 
			
		||||
                      />
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
            <Sidebar
 | 
			
		||||
              objectID={objectID!}
 | 
			
		||||
              title={title}
 | 
			
		||||
              gameDetails={gameDetails}
 | 
			
		||||
            />
 | 
			
		||||
                    <Sidebar />
 | 
			
		||||
                  </div>
 | 
			
		||||
                </section>
 | 
			
		||||
              )}
 | 
			
		||||
| 
						 | 
				
			
			@ -273,7 +130,12 @@ export function GameDetails() {
 | 
			
		|||
                  <div style={{ width: 16, height: 16, position: "relative" }}>
 | 
			
		||||
                    <Lottie
 | 
			
		||||
                      animationData={starsAnimation}
 | 
			
		||||
              style={{ width: 70, position: "absolute", top: -28, left: -27 }}
 | 
			
		||||
                      style={{
 | 
			
		||||
                        width: 70,
 | 
			
		||||
                        position: "absolute",
 | 
			
		||||
                        top: -28,
 | 
			
		||||
                        left: -27,
 | 
			
		||||
                      }}
 | 
			
		||||
                      loop
 | 
			
		||||
                    />
 | 
			
		||||
                  </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -282,4 +144,8 @@ export function GameDetails() {
 | 
			
		|||
              )}
 | 
			
		||||
            </SkeletonTheme>
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
      </GameDetailsContextConsumer>
 | 
			
		||||
    </GameDetailsContextProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,37 +1,14 @@
 | 
			
		|||
import { GameStatus, GameStatusHelper } from "@shared";
 | 
			
		||||
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
import { useDownload, useLibrary } from "@renderer/hooks";
 | 
			
		||||
import type { Game, GameRepack } from "@types";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { useContext, useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./hero-panel-actions.css";
 | 
			
		||||
import { gameDetailsContext } from "../game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface HeroPanelActionsProps {
 | 
			
		||||
  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) {
 | 
			
		||||
export function HeroPanelActions() {
 | 
			
		||||
  const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
 | 
			
		||||
    useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +20,16 @@ export function HeroPanelActions({
 | 
			
		|||
    isGameDeleting,
 | 
			
		||||
  } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    game,
 | 
			
		||||
    repacks,
 | 
			
		||||
    isGameRunning,
 | 
			
		||||
    objectID,
 | 
			
		||||
    gameTitle,
 | 
			
		||||
    openRepacksModal,
 | 
			
		||||
    updateGame,
 | 
			
		||||
  } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
  const { updateLibrary } = useLibrary();
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
| 
						 | 
				
			
			@ -86,15 +73,15 @@ export function HeroPanelActions({
 | 
			
		|||
        const gameExecutablePath = await selectGameExecutable();
 | 
			
		||||
 | 
			
		||||
        await window.electron.addGameToLibrary(
 | 
			
		||||
          objectID,
 | 
			
		||||
          title,
 | 
			
		||||
          objectID!,
 | 
			
		||||
          gameTitle,
 | 
			
		||||
          "steam",
 | 
			
		||||
          gameExecutablePath
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      updateLibrary();
 | 
			
		||||
      getGame();
 | 
			
		||||
      updateGame();
 | 
			
		||||
    } finally {
 | 
			
		||||
      setToggleLibraryGameDisabled(false);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -145,59 +132,14 @@ export function HeroPanelActions({
 | 
			
		|||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (game && isGameDownloading) {
 | 
			
		||||
  if (game?.progress === 1) {
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        <Button
 | 
			
		||||
          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) ? (
 | 
			
		||||
        {game?.progress === 1 ? (
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={openGameInstaller}
 | 
			
		||||
            theme="outline"
 | 
			
		||||
            disabled={deleting || isGamePlaying}
 | 
			
		||||
            disabled={deleting || isGameRunning}
 | 
			
		||||
            className={styles.heroPanelAction}
 | 
			
		||||
          >
 | 
			
		||||
            {t("install")}
 | 
			
		||||
| 
						 | 
				
			
			@ -206,7 +148,7 @@ export function HeroPanelActions({
 | 
			
		|||
          toggleGameOnLibraryButton
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {isGamePlaying ? (
 | 
			
		||||
        {isGameRunning ? (
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={closeGame}
 | 
			
		||||
            theme="outline"
 | 
			
		||||
| 
						 | 
				
			
			@ -219,7 +161,7 @@ export function HeroPanelActions({
 | 
			
		|||
          <Button
 | 
			
		||||
            onClick={openGame}
 | 
			
		||||
            theme="outline"
 | 
			
		||||
            disabled={deleting || isGamePlaying}
 | 
			
		||||
            disabled={deleting || isGameRunning}
 | 
			
		||||
            className={styles.heroPanelAction}
 | 
			
		||||
          >
 | 
			
		||||
            {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 (
 | 
			
		||||
      <>
 | 
			
		||||
        <Button
 | 
			
		||||
| 
						 | 
				
			
			@ -240,8 +224,9 @@ export function HeroPanelActions({
 | 
			
		|||
        >
 | 
			
		||||
          {t("open_download_options")}
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
          onClick={() => removeGameFromLibrary(game.id).then(getGame)}
 | 
			
		||||
          onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
 | 
			
		||||
          theme="outline"
 | 
			
		||||
          disabled={deleting}
 | 
			
		||||
          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 type { Game } from "@types";
 | 
			
		||||
import { useDate } from "@renderer/hooks";
 | 
			
		||||
import { gameDetailsContext } from "../game-details.context";
 | 
			
		||||
 | 
			
		||||
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
 | 
			
		||||
 | 
			
		||||
export interface HeroPanelPlaytimeProps {
 | 
			
		||||
  game: Game;
 | 
			
		||||
  isGamePlaying: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function HeroPanelPlaytime({
 | 
			
		||||
  game,
 | 
			
		||||
  isGamePlaying,
 | 
			
		||||
}: HeroPanelPlaytimeProps) {
 | 
			
		||||
export function HeroPanelPlaytime() {
 | 
			
		||||
  const [lastTimePlayed, setLastTimePlayed] = useState("");
 | 
			
		||||
 | 
			
		||||
  const { game, isGameRunning } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
  const { i18n, t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  const { formatDistance } = useDate();
 | 
			
		||||
| 
						 | 
				
			
			@ -52,8 +46,8 @@ export function HeroPanelPlaytime({
 | 
			
		|||
    return t("amount_hours", { amount: numberFormatter.format(hours) });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!game.lastTimePlayed) {
 | 
			
		||||
    return <p>{t("not_played_yet", { title: game.title })}</p>;
 | 
			
		||||
  if (!game?.lastTimePlayed) {
 | 
			
		||||
    return <p>{t("not_played_yet", { title: game?.title })}</p>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +58,7 @@ export function HeroPanelPlaytime({
 | 
			
		|||
        })}
 | 
			
		||||
      </p>
 | 
			
		||||
 | 
			
		||||
      {isGamePlaying ? (
 | 
			
		||||
      {isGameRunning ? (
 | 
			
		||||
        <p>{t("playing_now")}</p>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <p>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,72 +1,48 @@
 | 
			
		|||
import { format } from "date-fns";
 | 
			
		||||
import { useMemo, useState } from "react";
 | 
			
		||||
import { useContext, useMemo, useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import Color from "color";
 | 
			
		||||
 | 
			
		||||
import { useDownload } from "@renderer/hooks";
 | 
			
		||||
import type { Game, GameRepack } from "@types";
 | 
			
		||||
 | 
			
		||||
import { formatDownloadProgress } from "@renderer/helpers";
 | 
			
		||||
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 * as styles from "./hero-panel.css";
 | 
			
		||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
 | 
			
		||||
import { gameDetailsContext } from "../game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface HeroPanelProps {
 | 
			
		||||
  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) {
 | 
			
		||||
export function HeroPanel() {
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  const { game, repacks, gameColor } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
  const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    game: gameDownloading,
 | 
			
		||||
    progress,
 | 
			
		||||
    eta,
 | 
			
		||||
    numPeers,
 | 
			
		||||
    numSeeds,
 | 
			
		||||
    isGameDeleting,
 | 
			
		||||
  } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const isGameDownloading =
 | 
			
		||||
    gameDownloading?.id === game?.id &&
 | 
			
		||||
    GameStatusHelper.isDownloading(game?.status ?? null);
 | 
			
		||||
  const { progress, eta, lastPacket, isGameDeleting } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const finalDownloadSize = useMemo(() => {
 | 
			
		||||
    if (!game) return "N/A";
 | 
			
		||||
    if (game.fileSize) return formatBytes(game.fileSize);
 | 
			
		||||
 | 
			
		||||
    if (gameDownloading?.fileSize && isGameDownloading)
 | 
			
		||||
      return formatBytes(gameDownloading.fileSize);
 | 
			
		||||
    if (lastPacket?.game.fileSize && game?.status === "active")
 | 
			
		||||
      return formatBytes(lastPacket?.game.fileSize);
 | 
			
		||||
 | 
			
		||||
    return game.repack?.fileSize ?? "N/A";
 | 
			
		||||
  }, [game, isGameDownloading, gameDownloading]);
 | 
			
		||||
  }, [game, lastPacket?.game]);
 | 
			
		||||
 | 
			
		||||
  const getInfo = () => {
 | 
			
		||||
    if (isGameDeleting(game?.id ?? -1)) {
 | 
			
		||||
      return <p>{t("deleting")}</p>;
 | 
			
		||||
    if (isGameDeleting(game?.id ?? -1)) 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 (
 | 
			
		||||
        <>
 | 
			
		||||
          <p className={styles.downloadDetailsRow}>
 | 
			
		||||
| 
						 | 
				
			
			@ -74,33 +50,25 @@ export function HeroPanel({
 | 
			
		|||
            {eta && <small>{t("eta", { eta })}</small>}
 | 
			
		||||
          </p>
 | 
			
		||||
 | 
			
		||||
          {gameDownloading.status !== GameStatus.Downloading ? (
 | 
			
		||||
            <>
 | 
			
		||||
              <p>{t(gameDownloading.status)}</p>
 | 
			
		||||
              {eta && <small>{t("eta", { eta })}</small>}
 | 
			
		||||
            </>
 | 
			
		||||
          ) : (
 | 
			
		||||
          <p className={styles.downloadDetailsRow}>
 | 
			
		||||
              {formatBytes(gameDownloading.bytesDownloaded)} /{" "}
 | 
			
		||||
            {formatBytes(lastPacket?.game?.bytesDownloaded ?? 0)} /{" "}
 | 
			
		||||
            {finalDownloadSize}
 | 
			
		||||
            {game?.downloader === Downloader.Torrent && (
 | 
			
		||||
              <small>
 | 
			
		||||
                {game?.downloader === Downloader.Torrent &&
 | 
			
		||||
                  `${numPeers} peers / ${numSeeds} seeds`}
 | 
			
		||||
                {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
 | 
			
		||||
              </small>
 | 
			
		||||
            </p>
 | 
			
		||||
            )}
 | 
			
		||||
          </p>
 | 
			
		||||
        </>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (game?.status === GameStatus.Paused) {
 | 
			
		||||
    if (game?.status === "paused") {
 | 
			
		||||
      const formattedProgress = formatDownloadProgress(game.progress);
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <>
 | 
			
		||||
          <p>
 | 
			
		||||
            {t("paused_progress", {
 | 
			
		||||
              progress: formatDownloadProgress(game.progress),
 | 
			
		||||
            })}
 | 
			
		||||
          </p>
 | 
			
		||||
          <p>{t("paused_progress", { progress: formattedProgress })}</p>
 | 
			
		||||
          <p>
 | 
			
		||||
            {formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
 | 
			
		||||
          </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;
 | 
			
		||||
 | 
			
		||||
    if (latestRepack) {
 | 
			
		||||
| 
						 | 
				
			
			@ -129,6 +93,10 @@ export function HeroPanel({
 | 
			
		|||
    return <p>{t("no_downloads")}</p>;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const backgroundColor = gameColor
 | 
			
		||||
    ? (new Color(gameColor).darken(0.6).toString() as string)
 | 
			
		||||
    : "";
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <BinaryNotFoundModal
 | 
			
		||||
| 
						 | 
				
			
			@ -136,19 +104,11 @@ export function HeroPanel({
 | 
			
		|||
        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.actions}>
 | 
			
		||||
          <HeroPanelActions
 | 
			
		||||
            game={game}
 | 
			
		||||
            repacks={repacks}
 | 
			
		||||
            objectID={objectID}
 | 
			
		||||
            title={title}
 | 
			
		||||
            getGame={getGame}
 | 
			
		||||
            openRepacksModal={openRepacksModal}
 | 
			
		||||
            openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
 | 
			
		||||
            isGamePlaying={isGamePlaying}
 | 
			
		||||
            isGameDownloading={isGameDownloading}
 | 
			
		||||
          />
 | 
			
		||||
        </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";
 | 
			
		||||
 | 
			
		||||
export const slideIn = keyframes({
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { useState } from "react";
 | 
			
		||||
import { useContext, useState } from "react";
 | 
			
		||||
import { Trans, useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
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 { ArrowUpIcon } from "@primer/octicons-react";
 | 
			
		||||
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
 | 
			
		||||
import { gameDetailsContext } from "../../game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface DODIInstallationGuideProps {
 | 
			
		||||
  windowColor: string;
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DODIInstallationGuide({
 | 
			
		||||
  windowColor,
 | 
			
		||||
  visible,
 | 
			
		||||
  onClose,
 | 
			
		||||
}: DODIInstallationGuideProps) {
 | 
			
		||||
  const { gameColor } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  const [dontShowAgain, setDontShowAgain] = useState(false);
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +54,7 @@ export function DODIInstallationGuide({
 | 
			
		|||
 | 
			
		||||
        <div
 | 
			
		||||
          className={styles.windowContainer}
 | 
			
		||||
          style={{ backgroundColor: windowColor }}
 | 
			
		||||
          style={{ backgroundColor: gameColor }}
 | 
			
		||||
        >
 | 
			
		||||
          <div className={styles.windowContent}>
 | 
			
		||||
            <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";
 | 
			
		||||
 | 
			
		||||
export const passwordField = style({
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const repacks = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import { useEffect, useState } from "react";
 | 
			
		||||
import { useContext, useEffect, useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import { Button, Modal, TextField } from "@renderer/components";
 | 
			
		||||
| 
						 | 
				
			
			@ -6,20 +6,19 @@ import type { GameRepack } from "@types";
 | 
			
		|||
 | 
			
		||||
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 { SelectFolderModal } from "./select-folder-modal";
 | 
			
		||||
import { gameDetailsContext } from "../game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface RepacksModalProps {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  repacks: GameRepack[];
 | 
			
		||||
  startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function RepacksModal({
 | 
			
		||||
  visible,
 | 
			
		||||
  repacks,
 | 
			
		||||
  startDownload,
 | 
			
		||||
  onClose,
 | 
			
		||||
}: RepacksModalProps) {
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +26,8 @@ export function RepacksModal({
 | 
			
		|||
  const [repack, setRepack] = useState<GameRepack | null>(null);
 | 
			
		||||
  const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const { repacks } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const container = style({
 | 
			
		||||
  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 { Trans, useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import { DiskSpace } from "check-disk-space";
 | 
			
		||||
import * as styles from "./select-folder-modal.css";
 | 
			
		||||
import { Button, Link, Modal, TextField } from "@renderer/components";
 | 
			
		||||
import { DownloadIcon } from "@primer/octicons-react";
 | 
			
		||||
import { formatBytes } from "@shared";
 | 
			
		||||
 | 
			
		||||
import type { GameRepack } from "@types";
 | 
			
		||||
 | 
			
		||||
export interface SelectFolderModalProps {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  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 type {
 | 
			
		||||
  HowLongToBeatCategory,
 | 
			
		||||
  ShopDetails,
 | 
			
		||||
  SteamAppDetails,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./sidebar.css";
 | 
			
		||||
import { gameDetailsContext } from "../game-details.context";
 | 
			
		||||
 | 
			
		||||
export interface SidebarProps {
 | 
			
		||||
  objectID: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  gameDetails: ShopDetails | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
 | 
			
		||||
export function Sidebar() {
 | 
			
		||||
  const [howLongToBeat, setHowLongToBeat] = useState<{
 | 
			
		||||
    isLoading: boolean;
 | 
			
		||||
    data: HowLongToBeatCategory[] | null;
 | 
			
		||||
| 
						 | 
				
			
			@ -25,20 +16,24 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
 | 
			
		|||
  const [activeRequirement, setActiveRequirement] =
 | 
			
		||||
    useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
 | 
			
		||||
 | 
			
		||||
  const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (objectID) {
 | 
			
		||||
      setHowLongToBeat({ isLoading: true, data: null });
 | 
			
		||||
 | 
			
		||||
      window.electron
 | 
			
		||||
      .getHowLongToBeat(objectID, "steam", title)
 | 
			
		||||
        .getHowLongToBeat(objectID, "steam", gameTitle)
 | 
			
		||||
        .then((howLongToBeat) => {
 | 
			
		||||
          setHowLongToBeat({ isLoading: false, data: howLongToBeat });
 | 
			
		||||
        })
 | 
			
		||||
        .catch(() => {
 | 
			
		||||
          setHowLongToBeat({ isLoading: false, data: null });
 | 
			
		||||
        });
 | 
			
		||||
  }, [objectID, title]);
 | 
			
		||||
    }
 | 
			
		||||
  }, [objectID, gameTitle]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <aside className={styles.contentSidebar}>
 | 
			
		||||
| 
						 | 
				
			
			@ -73,9 +68,9 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
 | 
			
		|||
        className={styles.requirementsDetails}
 | 
			
		||||
        dangerouslySetInnerHTML={{
 | 
			
		||||
          __html:
 | 
			
		||||
            gameDetails?.pc_requirements?.[activeRequirement] ??
 | 
			
		||||
            shopDetails?.pc_requirements?.[activeRequirement] ??
 | 
			
		||||
            t(`no_${activeRequirement}_requirements`, {
 | 
			
		||||
              title,
 | 
			
		||||
              gameTitle,
 | 
			
		||||
            }),
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,22 +31,11 @@ export const formatBytes = (bytes: number): string => {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
export class GameStatusHelper {
 | 
			
		||||
  public static isDownloading(status: GameStatus | null) {
 | 
			
		||||
    return (
 | 
			
		||||
      status === GameStatus.Downloading ||
 | 
			
		||||
      status === GameStatus.DownloadingMetadata ||
 | 
			
		||||
      status === GameStatus.CheckingFiles
 | 
			
		||||
    );
 | 
			
		||||
  public static isDownloading(status: string | null) {
 | 
			
		||||
    return status === "active";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static isVerifying(status: GameStatus | null) {
 | 
			
		||||
    return (
 | 
			
		||||
      GameStatus.DownloadingMetadata == status ||
 | 
			
		||||
      GameStatus.CheckingFiles == status
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static isReady(status: GameStatus | null) {
 | 
			
		||||
    return status === GameStatus.Finished || status === GameStatus.Seeding;
 | 
			
		||||
  public static isReady(status: string | null) {
 | 
			
		||||
    return status === "complete";
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 CatalogueCategory = "recently_added" | "trending";
 | 
			
		||||
| 
						 | 
				
			
			@ -91,14 +92,12 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
 | 
			
		|||
  id: number;
 | 
			
		||||
  title: string;
 | 
			
		||||
  iconUrl: string;
 | 
			
		||||
  status: GameStatus | null;
 | 
			
		||||
  status: Aria2Status | null;
 | 
			
		||||
  folderName: string;
 | 
			
		||||
  downloadPath: string | null;
 | 
			
		||||
  repacks: GameRepack[];
 | 
			
		||||
  repack: GameRepack | null;
 | 
			
		||||
  progress: number;
 | 
			
		||||
  fileVerificationProgress: number;
 | 
			
		||||
  decompressionProgress: number;
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
  playTimeInMilliseconds: number;
 | 
			
		||||
  downloader: Downloader;
 | 
			
		||||
| 
						 | 
				
			
			@ -109,11 +108,15 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
 | 
			
		|||
  updatedAt: Date;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface TorrentProgress {
 | 
			
		||||
export interface DownloadProgress {
 | 
			
		||||
  downloadSpeed: number;
 | 
			
		||||
  timeRemaining: number;
 | 
			
		||||
  numPeers: number;
 | 
			
		||||
  numSeeds: number;
 | 
			
		||||
  downloadingMetadata: boolean;
 | 
			
		||||
  progress: number;
 | 
			
		||||
  bytesDownloaded: number;
 | 
			
		||||
  fileSize: number;
 | 
			
		||||
  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:
 | 
			
		||||
    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:
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  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"
 | 
			
		||||
  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"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
 | 
			
		||||
  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"
 | 
			
		||||
  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:
 | 
			
		||||
  version "8.17.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue