mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: adding new messages to hero panel
This commit is contained in:
parent
a240c3ae24
commit
d431c01d1b
28 changed files with 398 additions and 358 deletions
|
@ -10,7 +10,7 @@ const addGameToLibrary = async (
|
|||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
title: string,
|
||||
gameShop: GameShop,
|
||||
shop: GameShop,
|
||||
executablePath: string | null
|
||||
) => {
|
||||
return gameRepository
|
||||
|
@ -19,7 +19,7 @@ const addGameToLibrary = async (
|
|||
objectID,
|
||||
},
|
||||
{
|
||||
shop: gameShop,
|
||||
shop,
|
||||
status: null,
|
||||
executablePath,
|
||||
isDeleted: false,
|
||||
|
@ -40,7 +40,7 @@ const addGameToLibrary = async (
|
|||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
shop: gameShop,
|
||||
shop,
|
||||
executablePath,
|
||||
})
|
||||
.then(() => {
|
||||
|
|
|
@ -1,33 +1,18 @@
|
|||
import {
|
||||
gameRepository,
|
||||
repackRepository,
|
||||
userPreferencesRepository,
|
||||
} from "@main/repository";
|
||||
import { gameRepository, repackRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
import type { GameShop } from "@types";
|
||||
import type { StartGameDownloadPayload } from "@types";
|
||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
||||
import { DownloadManager } from "@main/services";
|
||||
import { Downloader } from "@shared";
|
||||
import { stateManager } from "@main/state-manager";
|
||||
import { Not } from "typeorm";
|
||||
|
||||
const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
gameShop: GameShop,
|
||||
downloadPath: string
|
||||
payload: StartGameDownloadPayload
|
||||
) => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const downloader = userPreferences?.realDebridApiToken
|
||||
? Downloader.RealDebrid
|
||||
: Downloader.Torrent;
|
||||
const { repackId, objectID, title, shop, downloadPath, downloader } = payload;
|
||||
|
||||
const [game, repack] = await Promise.all([
|
||||
gameRepository.findOne({
|
||||
|
@ -83,7 +68,7 @@ const startGameDownload = async (
|
|||
iconUrl,
|
||||
objectID,
|
||||
downloader,
|
||||
shop: gameShop,
|
||||
shop,
|
||||
status: "active",
|
||||
downloadPath,
|
||||
repack: { id: repackId },
|
||||
|
|
|
@ -5,8 +5,8 @@ import {
|
|||
getNewRepacksFromUser,
|
||||
getNewRepacksFromXatab,
|
||||
getNewRepacksFromOnlineFix,
|
||||
startProcessWatcher,
|
||||
DownloadManager,
|
||||
startMainLoop,
|
||||
} from "./services";
|
||||
import {
|
||||
gameRepository,
|
||||
|
@ -23,7 +23,7 @@ import { orderBy } from "lodash-es";
|
|||
import { SteamGame } from "@types";
|
||||
import { Not } from "typeorm";
|
||||
|
||||
startProcessWatcher();
|
||||
startMainLoop();
|
||||
|
||||
const track1337xUsers = async (existingRepacks: Repack[]) => {
|
||||
for (const repacker of repackersOn1337x) {
|
||||
|
@ -88,8 +88,6 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||
if (userPreferences?.realDebridApiToken)
|
||||
await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||
|
||||
await DownloadManager.connect();
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
status: "active",
|
||||
|
@ -99,9 +97,7 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||
relations: { repack: true },
|
||||
});
|
||||
|
||||
if (game) {
|
||||
DownloadManager.startDownload(game.id);
|
||||
}
|
||||
if (game) DownloadManager.startDownload(game.id);
|
||||
};
|
||||
|
||||
userPreferencesRepository
|
||||
|
|
|
@ -6,7 +6,7 @@ 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 { Notification, app } from "electron";
|
||||
import { t } from "i18next";
|
||||
import { Downloader } from "@shared";
|
||||
import { DownloadProgress } from "@types";
|
||||
|
@ -16,33 +16,36 @@ import { Game } from "@main/entity";
|
|||
export class DownloadManager {
|
||||
private static downloads = new Map<number, string>();
|
||||
|
||||
private static connected = false;
|
||||
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"
|
||||
);
|
||||
private static connect(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2", "aria2c")
|
||||
: path.join(__dirname, "..", "..", "aria2", "aria2c");
|
||||
|
||||
spawn(
|
||||
binary,
|
||||
[
|
||||
const cp = spawn(binaryPath, [
|
||||
"--enable-rpc",
|
||||
"--rpc-listen-all",
|
||||
"--file-allocation=none",
|
||||
"--allow-overwrite=true",
|
||||
],
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
]);
|
||||
|
||||
await this.aria2.open();
|
||||
this.attachListener();
|
||||
cp.stdout.on("data", async (data) => {
|
||||
const msg = Buffer.from(data).toString("utf-8");
|
||||
|
||||
if (msg.includes("IPv6 RPC: listening on TCP")) {
|
||||
await this.aria2.open();
|
||||
this.connected = true;
|
||||
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static getETA(status: StatusResponse) {
|
||||
|
@ -84,91 +87,78 @@ export class DownloadManager {
|
|||
return "";
|
||||
}
|
||||
|
||||
private static async attachListener() {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
if (!this.gid || !this.gameId) {
|
||||
continue;
|
||||
public static async watchDownloads() {
|
||||
if (!this.gid || !this.gameId) return;
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress =
|
||||
Number(status.completedLength) / Number(status.totalLength);
|
||||
|
||||
if (!downloadingMetadata) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded: Number(status.completedLength),
|
||||
fileSize: Number(status.totalLength),
|
||||
status: status.status,
|
||||
};
|
||||
|
||||
if (!isNaN(progress)) update.progress = progress;
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.gameId },
|
||||
{
|
||||
...update,
|
||||
status: status.status,
|
||||
folderName: this.getFolderName(status),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const status = await this.aria2.call("tellStatus", this.gid);
|
||||
const game = await gameRepository.findOne({
|
||||
where: { id: this.gameId, isDeleted: false },
|
||||
relations: { repack: true },
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (!downloadingMetadata) {
|
||||
const update: QueryDeepPartialEntity<Game> = {
|
||||
bytesDownloaded: Number(status.completedLength),
|
||||
fileSize: Number(status.totalLength),
|
||||
status: status.status,
|
||||
};
|
||||
|
||||
if (!isNaN(progress)) update.progress = progress;
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.gameId },
|
||||
{
|
||||
...update,
|
||||
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));
|
||||
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 ? -1 : progress);
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async getGame(gameId: number) {
|
||||
|
@ -227,6 +217,8 @@ export class DownloadManager {
|
|||
}
|
||||
|
||||
static async startDownload(gameId: number) {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
const game = await this.getGame(gameId)!;
|
||||
|
||||
if (game) {
|
||||
|
|
|
@ -8,3 +8,4 @@ export * from "./window-manager";
|
|||
export * from "./download-manager";
|
||||
export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
|
|
16
src/main/services/main-loop.ts
Normal file
16
src/main/services/main-loop.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { DownloadManager } from "./download-manager";
|
||||
import { watchProcesses } from "./process-watcher";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const startMainLoop = async () => {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
await Promise.allSettled([
|
||||
watchProcesses(),
|
||||
DownloadManager.watchDownloads(),
|
||||
]);
|
||||
|
||||
await sleep(500);
|
||||
}
|
||||
};
|
|
@ -5,73 +5,58 @@ import { gameRepository } from "@main/repository";
|
|||
import { getProcesses } from "@main/helpers";
|
||||
import { WindowManager } from "./window-manager";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const gamesPlaytime = new Map<number, number>();
|
||||
|
||||
export const startProcessWatcher = async () => {
|
||||
const sleepTime = 500;
|
||||
const gamesPlaytime = new Map<number, number>();
|
||||
export const watchProcesses = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
executablePath: Not(IsNull()),
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
executablePath: Not(IsNull()),
|
||||
isDeleted: false,
|
||||
},
|
||||
if (games.length === 0) return;
|
||||
|
||||
const processes = await getProcesses();
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath!;
|
||||
const basename = path.win32.basename(executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
executablePath,
|
||||
path.extname(executablePath)
|
||||
);
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(runningProcess.name);
|
||||
});
|
||||
|
||||
if (games.length === 0) {
|
||||
await sleep(sleepTime);
|
||||
continue;
|
||||
}
|
||||
if (gameProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
const zero = gamesPlaytime.get(game.id) ?? 0;
|
||||
const delta = performance.now() - zero;
|
||||
|
||||
const processes = await getProcesses();
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath!;
|
||||
const basename = path.win32.basename(executablePath);
|
||||
const basenameWithoutExtension = path.win32.basename(
|
||||
executablePath,
|
||||
path.extname(executablePath)
|
||||
);
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
if (process.platform === "win32") {
|
||||
return runningProcess.name === basename;
|
||||
}
|
||||
|
||||
return [basename, basenameWithoutExtension].includes(
|
||||
runningProcess.name
|
||||
);
|
||||
});
|
||||
|
||||
if (gameProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
const zero = gamesPlaytime.get(game.id) ?? 0;
|
||||
const delta = performance.now() - zero;
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
|
||||
}
|
||||
|
||||
await gameRepository.update(game.id, {
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
||||
});
|
||||
|
||||
gameRepository.update(game.id, {
|
||||
lastTimePlayed: new Date().toUTCString(),
|
||||
});
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, performance.now());
|
||||
} else if (gamesPlaytime.has(game.id)) {
|
||||
gamesPlaytime.delete(game.id);
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
|
||||
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
|
||||
}
|
||||
|
||||
await gameRepository.update(game.id, {
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
||||
lastTimePlayed: new Date().toUTCString(),
|
||||
});
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, performance.now());
|
||||
} else if (gamesPlaytime.has(game.id)) {
|
||||
gamesPlaytime.delete(game.id);
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(sleepTime);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,26 +7,14 @@ import type {
|
|||
GameShop,
|
||||
DownloadProgress,
|
||||
UserPreferences,
|
||||
AppUpdaterEvents,
|
||||
AppUpdaterEvent,
|
||||
StartGameDownloadPayload,
|
||||
} from "@types";
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
/* Torrenting */
|
||||
startGameDownload: (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"startGameDownload",
|
||||
repackId,
|
||||
objectID,
|
||||
title,
|
||||
shop,
|
||||
downloadPath
|
||||
),
|
||||
startGameDownload: (payload: StartGameDownloadPayload) =>
|
||||
ipcRenderer.invoke("startGameDownload", payload),
|
||||
cancelGameDownload: (gameId: number) =>
|
||||
ipcRenderer.invoke("cancelGameDownload", gameId),
|
||||
pauseGameDownload: (gameId: number) =>
|
||||
|
@ -115,10 +103,10 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
platform: process.platform,
|
||||
|
||||
/* Splash */
|
||||
onAutoUpdaterEvent: (cb: (value: AppUpdaterEvents) => void) => {
|
||||
onAutoUpdaterEvent: (cb: (value: AppUpdaterEvent) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
value: AppUpdaterEvents
|
||||
value: AppUpdaterEvent
|
||||
) => cb(value);
|
||||
|
||||
ipcRenderer.on("autoUpdaterEvent", listener);
|
||||
|
|
|
@ -15,8 +15,7 @@ export function BottomPanel() {
|
|||
|
||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
lastPacket?.game && lastPacket?.game.status === "active";
|
||||
const isGameDownloading = !!lastPacket?.game;
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
|
|
|
@ -18,7 +18,6 @@ const base = style({
|
|||
},
|
||||
":disabled": {
|
||||
opacity: vars.opacity.disabled,
|
||||
pointerEvents: "none",
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
});
|
||||
|
@ -30,6 +29,9 @@ export const button = styleVariants({
|
|||
":hover": {
|
||||
backgroundColor: "#DADBE1",
|
||||
},
|
||||
":disabled": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
],
|
||||
outline: [
|
||||
|
@ -41,6 +43,9 @@ export const button = styleVariants({
|
|||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
":disabled": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
},
|
||||
],
|
||||
dark: [
|
||||
|
|
|
@ -28,7 +28,6 @@ export const content = recipe({
|
|||
flexDirection: "column",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
paddingBottom: "0",
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
},
|
||||
|
|
|
@ -48,10 +48,8 @@ export const toast = recipe({
|
|||
|
||||
export const toastContent = style({
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 5}px`,
|
||||
paddingLeft: `${SPACING_UNIT * 2}px`,
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
@ -63,13 +61,11 @@ export const progress = style({
|
|||
backgroundColor: vars.color.darkBackground,
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: "#1c9749",
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
||||
export const closeButton = style({
|
||||
position: "absolute",
|
||||
right: `${SPACING_UNIT}px`,
|
||||
color: vars.color.bodyText,
|
||||
cursor: "pointer",
|
||||
padding: "0",
|
||||
|
@ -77,5 +73,9 @@ export const closeButton = style({
|
|||
});
|
||||
|
||||
export const successIcon = style({
|
||||
color: "#1c9749",
|
||||
color: vars.color.success,
|
||||
});
|
||||
|
||||
export const errorIcon = style({
|
||||
color: vars.color.danger,
|
||||
});
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
XCircleFillIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import * as styles from "./toast.css";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export interface ToastProps {
|
||||
visible: boolean;
|
||||
|
@ -78,8 +78,13 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
|||
return (
|
||||
<div className={styles.toast({ closing: isClosing })}>
|
||||
<div className={styles.toastContent}>
|
||||
<CheckCircleFillIcon className={styles.successIcon} />
|
||||
<span style={{ fontWeight: "bold" }}>{message}</span>
|
||||
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{type === "success" && (
|
||||
<CheckCircleFillIcon className={styles.successIcon} />
|
||||
)}
|
||||
{type === "error" && <XCircleFillIcon className={styles.errorIcon} />}
|
||||
<span style={{ fontWeight: "bold" }}>{message}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
@ -87,7 +92,7 @@ export function Toast({ visible, message, type, onClose }: ToastProps) {
|
|||
onClick={startAnimateClosing}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
<XCircleIcon />
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
9
src/renderer/src/declaration.d.ts
vendored
9
src/renderer/src/declaration.d.ts
vendored
|
@ -10,6 +10,7 @@ import type {
|
|||
Steam250Game,
|
||||
DownloadProgress,
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
|
@ -21,13 +22,7 @@ declare global {
|
|||
|
||||
interface Electron {
|
||||
/* Torrenting */
|
||||
startGameDownload: (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) => Promise<Game>;
|
||||
startGameDownload: (payload: StartGameDownloadPayload) => Promise<Game>;
|
||||
cancelGameDownload: (gameId: number) => Promise<void>;
|
||||
pauseGameDownload: (gameId: number) => Promise<void>;
|
||||
resumeGameDownload: (gameId: number) => Promise<void>;
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
setGameDeleting,
|
||||
removeGameFromDeleting,
|
||||
} from "@renderer/features";
|
||||
import type { DownloadProgress, GameShop } from "@types";
|
||||
import type { DownloadProgress, StartGameDownloadPayload } from "@types";
|
||||
import { useDate } from "./use-date";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
|
@ -22,21 +22,13 @@ export function useDownload() {
|
|||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const startDownload = (
|
||||
repackId: number,
|
||||
objectID: string,
|
||||
title: string,
|
||||
shop: GameShop,
|
||||
downloadPath: string
|
||||
) =>
|
||||
window.electron
|
||||
.startGameDownload(repackId, objectID, title, shop, downloadPath)
|
||||
.then((game) => {
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
const startDownload = (payload: StartGameDownloadPayload) =>
|
||||
window.electron.startGameDownload(payload).then((game) => {
|
||||
dispatch(clearDownload());
|
||||
updateLibrary();
|
||||
|
||||
return game;
|
||||
});
|
||||
return game;
|
||||
});
|
||||
|
||||
const pauseDownload = async (gameId: number) => {
|
||||
await window.electron.pauseGameDownload(gameId);
|
||||
|
@ -62,11 +54,11 @@ export function useDownload() {
|
|||
});
|
||||
|
||||
const getETA = () => {
|
||||
if (lastPacket && lastPacket.timeRemaining < 0) return "";
|
||||
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
|
||||
|
||||
try {
|
||||
return formatDistance(
|
||||
addMilliseconds(new Date(), lastPacket?.timeRemaining ?? 1),
|
||||
addMilliseconds(new Date(), lastPacket.timeRemaining),
|
||||
new Date(),
|
||||
{ addSuffix: true }
|
||||
);
|
||||
|
@ -94,7 +86,7 @@ export function useDownload() {
|
|||
|
||||
return {
|
||||
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
|
||||
progress: formatDownloadProgress(lastPacket?.game.progress ?? 0),
|
||||
progress: formatDownloadProgress(lastPacket?.game.progress),
|
||||
lastPacket,
|
||||
eta: getETA(),
|
||||
startDownload,
|
||||
|
|
|
@ -254,9 +254,7 @@ export function Downloads() {
|
|||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() =>
|
||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
||||
}
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
|
|
|
@ -67,6 +67,7 @@ export const mediaPreviewButton = recipe({
|
|||
transition: "translate 0.3s ease-in-out, opacity 0.2s ease",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
overflow: "hidden",
|
||||
":hover": {
|
||||
opacity: "0.8",
|
||||
},
|
||||
|
@ -84,7 +85,6 @@ export const mediaPreview = style({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flex: "1",
|
||||
});
|
||||
|
||||
export const gallerySliderButton = recipe({
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
OnlineFixInstallationGuide,
|
||||
RepacksModal,
|
||||
} from "./modals";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export interface GameDetailsContext {
|
||||
game: Game | null;
|
||||
|
@ -138,15 +139,17 @@ export function GameDetailsContextProvider({
|
|||
|
||||
const handleStartDownload = async (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => {
|
||||
await startDownload(
|
||||
repack.id,
|
||||
objectID!,
|
||||
gameTitle,
|
||||
shop as GameShop,
|
||||
downloadPath
|
||||
);
|
||||
await startDownload({
|
||||
repackId: repack.id,
|
||||
objectID: objectID!,
|
||||
title: gameTitle,
|
||||
downloader,
|
||||
shop: shop as GameShop,
|
||||
downloadPath,
|
||||
});
|
||||
|
||||
await updateGame();
|
||||
setShowRepacksModal(false);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const panel = style({
|
||||
width: "100%",
|
||||
|
@ -11,6 +12,8 @@ export const panel = style({
|
|||
justifyContent: "space-between",
|
||||
transition: "all ease 0.2s",
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
|
@ -29,3 +32,27 @@ export const downloadDetailsRow = style({
|
|||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
});
|
||||
|
||||
export const progressBar = recipe({
|
||||
base: {
|
||||
position: "absolute",
|
||||
bottom: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "3px",
|
||||
transition: "all ease 0.2s",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -33,29 +33,46 @@ export function HeroPanel() {
|
|||
return game.repack?.fileSize ?? "N/A";
|
||||
}, [game, lastPacket?.game]);
|
||||
|
||||
const isGameDownloading =
|
||||
game?.status === "active" && lastPacket?.game.id === game?.id;
|
||||
|
||||
const getInfo = () => {
|
||||
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>;
|
||||
|
||||
if (game?.progress === 1) return <HeroPanelPlaytime />;
|
||||
|
||||
console.log(lastPacket?.game.id, game?.id);
|
||||
|
||||
if (game?.status === "active" && lastPacket?.game.id === game?.id) {
|
||||
if (lastPacket?.downloadingMetadata) {
|
||||
return <p>{t("downloading_metadata")}</p>;
|
||||
if (game?.status === "active") {
|
||||
if (lastPacket?.downloadingMetadata && isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("downloading_metadata")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeDownloaded = formatBytes(
|
||||
lastPacket?.game?.bytesDownloaded ?? game?.bytesDownloaded
|
||||
);
|
||||
|
||||
const showPeers =
|
||||
game?.downloader === Downloader.Torrent &&
|
||||
lastPacket?.numPeers !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{progress}
|
||||
{isGameDownloading
|
||||
? progress
|
||||
: formatDownloadProgress(game?.progress)}
|
||||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
</p>
|
||||
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{formatBytes(lastPacket?.game?.bytesDownloaded ?? 0)} /{" "}
|
||||
{finalDownloadSize}
|
||||
{game?.downloader === Downloader.Torrent && (
|
||||
<span>
|
||||
{sizeDownloaded} / {finalDownloadSize}
|
||||
</span>
|
||||
{showPeers && (
|
||||
<small>
|
||||
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
|
||||
</small>
|
||||
|
@ -99,6 +116,10 @@ export function HeroPanel() {
|
|||
? (new Color(gameColor).darken(0.6).toString() as string)
|
||||
: "";
|
||||
|
||||
const showProgressBar =
|
||||
(game?.status === "active" && game?.progress < 1) ||
|
||||
game?.status === "paused";
|
||||
|
||||
return (
|
||||
<>
|
||||
<BinaryNotFoundModal
|
||||
|
@ -113,6 +134,18 @@ export function HeroPanel() {
|
|||
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showProgressBar && (
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
isGameDownloading ? lastPacket?.game.progress : game?.progress
|
||||
}
|
||||
className={styles.progressBar({
|
||||
disabled: game?.status === "paused",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -10,10 +10,15 @@ import { SPACING_UNIT } from "../../../theme.css";
|
|||
import { format } from "date-fns";
|
||||
import { SelectFolderModal } from "./select-folder-modal";
|
||||
import { gameDetailsContext } from "../game-details.context";
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
visible: boolean;
|
||||
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,15 +4,19 @@ 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 { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes } from "@shared";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
import type { GameRepack, UserPreferences } from "@types";
|
||||
|
||||
export interface SelectFolderModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
|
||||
startDownload: (
|
||||
repack: GameRepack,
|
||||
downloader: Downloader,
|
||||
downloadPath: string
|
||||
) => Promise<void>;
|
||||
repack: GameRepack | null;
|
||||
}
|
||||
|
||||
|
@ -27,6 +31,11 @@ export function SelectFolderModal({
|
|||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [userPreferences, setUserPreferences] =
|
||||
useState<UserPreferences | null>(null);
|
||||
const [selectedDownloader, setSelectedDownloader] = useState(
|
||||
Downloader.Torrent
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
visible && getDiskFreeSpace(selectedPath);
|
||||
|
@ -38,6 +47,11 @@ export function SelectFolderModal({
|
|||
window.electron.getUserPreferences(),
|
||||
]).then(([path, userPreferences]) => {
|
||||
setSelectedPath(userPreferences?.downloadsPath || path);
|
||||
setUserPreferences(userPreferences);
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
setSelectedDownloader(Downloader.RealDebrid);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
@ -63,7 +77,7 @@ export function SelectFolderModal({
|
|||
if (repack) {
|
||||
setDownloadStarting(true);
|
||||
|
||||
startDownload(repack, selectedPath).finally(() => {
|
||||
startDownload(repack, selectedDownloader, selectedPath).finally(() => {
|
||||
setDownloadStarting(false);
|
||||
onClose();
|
||||
});
|
||||
|
@ -73,7 +87,7 @@ export function SelectFolderModal({
|
|||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("download_path")}
|
||||
title="Download options"
|
||||
description={t("space_left_on_disk", {
|
||||
space: formatBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
|
@ -81,23 +95,43 @@ export function SelectFolderModal({
|
|||
>
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<label style={{ marginBottom: 0, padding: 0 }}>Download method</label>
|
||||
<label style={{ marginBottom: 0, padding: 0 }}>Method</label>
|
||||
|
||||
<div className={styles.downloaders}>
|
||||
<Button className={styles.downloaderOption} theme="outline">
|
||||
<Button
|
||||
className={styles.downloaderOption}
|
||||
theme={
|
||||
selectedDownloader === Downloader.Torrent
|
||||
? "primary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => setSelectedDownloader(Downloader.Torrent)}
|
||||
>
|
||||
{selectedDownloader === Downloader.Torrent && (
|
||||
<CheckCircleFillIcon />
|
||||
)}
|
||||
Torrent
|
||||
</Button>
|
||||
<Button className={styles.downloaderOption}>Real Debrid</Button>
|
||||
<Button
|
||||
className={styles.downloaderOption}
|
||||
theme={
|
||||
selectedDownloader === Downloader.RealDebrid
|
||||
? "primary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => setSelectedDownloader(Downloader.RealDebrid)}
|
||||
disabled={!userPreferences?.realDebridApiToken}
|
||||
>
|
||||
{selectedDownloader === Downloader.RealDebrid && (
|
||||
<CheckCircleFillIcon />
|
||||
)}
|
||||
Real Debrid
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
value={selectedPath}
|
||||
readOnly
|
||||
disabled
|
||||
label="Download path"
|
||||
/>
|
||||
<TextField value={selectedPath} readOnly disabled label="Path" />
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
|
|
|
@ -9,6 +9,8 @@ export const [themeClass, vars] = createTheme({
|
|||
muted: "#c0c1c7",
|
||||
bodyText: "#8e919b",
|
||||
border: "#424244",
|
||||
success: "#1c9749",
|
||||
danger: "#e11d48",
|
||||
},
|
||||
opacity: {
|
||||
disabled: "0.5",
|
||||
|
|
|
@ -148,7 +148,7 @@ export interface SteamGame {
|
|||
clientIcon: string | null;
|
||||
}
|
||||
|
||||
export type AppUpdaterEvents =
|
||||
export type AppUpdaterEvent =
|
||||
| { type: "error" }
|
||||
| { type: "checking-for-updates" }
|
||||
| { type: "update-not-available" }
|
||||
|
@ -156,3 +156,13 @@ export type AppUpdaterEvents =
|
|||
| { type: "update-downloaded" }
|
||||
| { type: "download-progress"; info: ProgressInfo }
|
||||
| { type: "update-cancelled" };
|
||||
|
||||
/* Events */
|
||||
export interface StartGameDownloadPayload {
|
||||
repackId: number;
|
||||
objectID: string;
|
||||
title: string;
|
||||
shop: GameShop;
|
||||
downloadPath: string;
|
||||
downloader: Downloader;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue