Merge pull request #567 from hydralauncher/feature/download-queue

Feature/download queue
This commit is contained in:
Zamitto 2024-06-04 20:44:06 -03:00 committed by GitHub
commit d6e57c20c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 641 additions and 461 deletions

File diff suppressed because one or more lines are too long

View File

@ -14,12 +14,8 @@
"paused": "{{title}} (Paused)", "paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)", "downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter library", "filter": "Filter library",
"follow_us": "Follow us",
"home": "Home", "home": "Home",
"discord": "Join our Discord", "queued": "{{title}} (Queued)"
"telegram": "Join our Telegram",
"x": "Follow on X",
"github": "Contribute on GitHub"
}, },
"header": { "header": {
"search": "Search games", "search": "Search games",
@ -133,7 +129,11 @@
"remove_from_list": "Remove", "remove_from_list": "Remove",
"delete_modal_title": "Are you sure?", "delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer", "delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install" "install": "Install",
"download_in_progress": "In progress",
"queued_downloads": "Queued downloads",
"downloads_completed": "Completed",
"queued": "Queued"
}, },
"settings": { "settings": {
"downloads_path": "Downloads path", "downloads_path": "Downloads path",

View File

@ -1,7 +1,6 @@
{ {
"home": { "home": {
"featured": "Destaque", "featured": "Destaque",
"recently_added": "Recém adicionados",
"trending": "Populares", "trending": "Populares",
"surprise_me": "Surpreenda-me", "surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado" "no_results": "Nenhum resultado encontrado"
@ -17,10 +16,7 @@
"filter": "Filtrar biblioteca", "filter": "Filtrar biblioteca",
"home": "Início", "home": "Início",
"follow_us": "Acompanhe-nos", "follow_us": "Acompanhe-nos",
"discord": "Entre no nosso Discord", "queued": "{{title}} (Na fila)"
"telegram": "Entre no nosso Telegram",
"x": "Siga-nos no X",
"github": "Contribua no GitHub"
}, },
"header": { "header": {
"search": "Buscar jogos", "search": "Buscar jogos",
@ -130,7 +126,11 @@
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador", "delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?", "delete_modal_title": "Tem certeza?",
"deleting": "Excluindo instalador…", "deleting": "Excluindo instalador…",
"install": "Instalar" "install": "Instalar",
"download_in_progress": "Baixando agora",
"queued_downloads": "Na fila",
"downloads_completed": "Completo",
"queued": "Na fila"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",

View File

@ -1,5 +1,6 @@
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import { import {
DownloadQueue,
DownloadSource, DownloadSource,
Game, Game,
GameShopCache, GameShopCache,
@ -16,7 +17,14 @@ export const createDataSource = (
) => ) =>
new DataSource({ new DataSource({
type: "better-sqlite3", type: "better-sqlite3",
entities: [Game, Repack, UserPreferences, GameShopCache, DownloadSource], entities: [
Game,
Repack,
UserPreferences,
GameShopCache,
DownloadSource,
DownloadQueue,
],
synchronize: true, synchronize: true,
database: databasePath, database: databasePath,
...options, ...options,

View File

@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { Game } from "./game.entity";
@Entity("download_queue")
export class DownloadQueue {
@PrimaryGeneratedColumn()
id: number;
@OneToOne("Game", "downloadQueue")
@JoinColumn()
game: Game;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -6,7 +6,8 @@ import {
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
} from "typeorm"; } from "typeorm";
import { Repack } from "./repack.entity"; import type { Repack } from "./repack.entity";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
@Entity("download_source") @Entity("download_source")
@ -26,7 +27,7 @@ export class DownloadSource {
@Column("text", { default: DownloadSourceStatus.UpToDate }) @Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus; status: DownloadSourceStatus;
@OneToMany(() => Repack, (repack) => repack.downloadSource, { cascade: true }) @OneToMany("Repack", "downloadSource", { cascade: true })
repacks: Repack[]; repacks: Repack[];
@CreateDateColumn() @CreateDateColumn()

View File

@ -12,6 +12,7 @@ import { Repack } from "./repack.entity";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import type { Aria2Status } from "aria2"; import type { Aria2Status } from "aria2";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game") @Entity("game")
export class Game { export class Game {
@ -66,10 +67,16 @@ export class Game {
@Column("text", { nullable: true }) @Column("text", { nullable: true })
uri: string | null; uri: string | null;
@OneToOne(() => Repack, { nullable: true }) /**
* @deprecated
*/
@OneToOne("Repack", "game", { nullable: true })
@JoinColumn() @JoinColumn()
repack: Repack; repack: Repack;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@Column("boolean", { default: false }) @Column("boolean", { default: false })
isDeleted: boolean; isDeleted: boolean;

View File

@ -3,3 +3,4 @@ export * from "./repack.entity";
export * from "./user-preferences.entity"; export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity"; export * from "./game-shop-cache.entity";
export * from "./download-source.entity"; export * from "./download-source.entity";
export * from "./download-queue.entity";

View File

@ -19,6 +19,9 @@ export class Repack {
@Column("text", { unique: true }) @Column("text", { unique: true })
magnet: string; magnet: string;
/**
* @deprecated
*/
@Column("int", { nullable: true }) @Column("int", { nullable: true })
page: number; page: number;

View File

@ -4,7 +4,8 @@ import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers"; import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { stateManager } from "@main/state-manager";
import { steamGamesWorker } from "@main/workers";
const addGameToLibrary = async ( const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -27,9 +28,9 @@ const addGameToLibrary = async (
) )
.then(async ({ affected }) => { .then(async ({ affected }) => {
if (!affected) { if (!affected) {
const steamGame = stateManager const steamGame = await steamGamesWorker.run(Number(objectID), {
.getValue("steamGames") name: "getById",
.find((game) => game.id === Number(objectID)); });
const iconUrl = steamGame?.clientIcon const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon) ? getSteamAppAsset("icon", objectID, steamGame.clientIcon)

View File

@ -6,8 +6,11 @@ const getLibrary = async () =>
where: { where: {
isDeleted: false, isDeleted: false,
}, },
relations: {
downloadQueue: true,
},
order: { order: {
updatedAt: "desc", createdAt: "desc",
}, },
}); });

View File

@ -1,25 +1,31 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const cancelGameDownload = async ( const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number gameId: number
) => { ) => {
await DownloadManager.cancelDownload(gameId); await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.cancelDownload(gameId);
await gameRepository.update( await transactionalEntityManager.getRepository(DownloadQueue).delete({
{ game: { id: gameId },
id: gameId, });
},
{ await transactionalEntityManager.getRepository(Game).update(
status: "removed", {
bytesDownloaded: 0, id: gameId,
progress: 0, },
} {
); status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
}; };
registerEvent("cancelGameDownload", cancelGameDownload); registerEvent("cancelGameDownload", cancelGameDownload);

View File

@ -1,13 +1,24 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const pauseGameDownload = async ( const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
gameId: number gameId: number
) => { ) => {
await DownloadManager.pauseDownload(); await dataSource.transaction(async (transactionalEntityManager) => {
await gameRepository.update({ id: gameId }, { status: "paused" }); await DownloadManager.pauseDownload();
await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "paused" });
});
}; };
registerEvent("pauseGameDownload", pauseGameDownload); registerEvent("pauseGameDownload", pauseGameDownload);

View File

@ -5,7 +5,7 @@ import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { dataSource } from "@main/data-source"; import { dataSource } from "@main/data-source";
import { Game } from "@main/entity"; import { DownloadQueue, Game } from "@main/entity";
const resumeGameDownload = async ( const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -30,6 +30,14 @@ const resumeGameDownload = async (
await DownloadManager.resumeDownload(game); await DownloadManager.resumeDownload(game);
await transactionalEntityManager
.getRepository(DownloadQueue)
.delete({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(DownloadQueue)
.insert({ game: { id: gameId } });
await transactionalEntityManager await transactionalEntityManager
.getRepository(Game) .getRepository(Game)
.update({ id: gameId }, { status: "active" }); .update({ id: gameId }, { status: "active" });

View File

@ -1,12 +1,17 @@
import { gameRepository, repackRepository } from "@main/repository"; import {
downloadQueueRepository,
gameRepository,
repackRepository,
} from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types"; import type { StartGameDownloadPayload } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers"; import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { stateManager } from "@main/state-manager";
import { Not } from "typeorm"; import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
const startGameDownload = async ( const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -53,9 +58,9 @@ const startGameDownload = async (
} }
); );
} else { } else {
const steamGame = stateManager const steamGame = await steamGamesWorker.run(Number(objectID), {
.getValue("steamGames") name: "getById",
.find((game) => game.id === Number(objectID)); });
const iconUrl = steamGame?.clientIcon const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", objectID, steamGame.clientIcon) ? getSteamAppAsset("icon", objectID, steamGame.clientIcon)
@ -89,6 +94,9 @@ const startGameDownload = async (
}, },
}); });
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await DownloadManager.startDownload(updatedGame!); await DownloadManager.startDownload(updatedGame!);
}; };

View File

@ -1,8 +1,10 @@
import { DownloadManager, RepacksManager, startMainLoop } from "./services"; import { DownloadManager, RepacksManager, startMainLoop } from "./services";
import { gameRepository, userPreferencesRepository } from "./repository"; import {
downloadQueueRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity"; import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid"; import { RealDebridClient } from "./services/real-debrid";
import { Not } from "typeorm";
import { fetchDownloadSourcesAndUpdate } from "./helpers"; import { fetchDownloadSourcesAndUpdate } from "./helpers";
startMainLoop(); startMainLoop();
@ -15,15 +17,17 @@ const loadState = async (userPreferences: UserPreferences | null) => {
if (userPreferences?.realDebridApiToken) if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken); RealDebridClient.authorize(userPreferences?.realDebridApiToken);
const game = await gameRepository.findOne({ const [nextQueueItem] = await downloadQueueRepository.find({
where: { order: {
status: "active", id: "DESC",
progress: Not(1), },
isDeleted: false, relations: {
game: true,
}, },
}); });
if (game) DownloadManager.startDownload(game); if (nextQueueItem?.game.status === "active")
DownloadManager.startDownload(nextQueueItem.game);
fetchDownloadSourcesAndUpdate(); fetchDownloadSourcesAndUpdate();
}; };

View File

@ -1,5 +1,6 @@
import { dataSource } from "./data-source"; import { dataSource } from "./data-source";
import { import {
DownloadQueue,
DownloadSource, DownloadSource,
Game, Game,
GameShopCache, GameShopCache,
@ -18,3 +19,5 @@ export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadSourceRepository = export const downloadSourceRepository =
dataSource.getRepository(DownloadSource); dataSource.getRepository(DownloadSource);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);

View File

@ -1,6 +1,10 @@
import Aria2, { StatusResponse } from "aria2"; import Aria2, { StatusResponse } from "aria2";
import { gameRepository, userPreferencesRepository } from "@main/repository"; import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
@ -194,19 +198,6 @@ export class DownloadManager {
where: { id: this.game.id, isDeleted: false }, where: { id: this.game.id, isDeleted: false },
}); });
if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification();
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
}
if (WindowManager.mainWindow && game) { if (WindowManager.mainWindow && game) {
if (!isNaN(progress)) if (!isNaN(progress))
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
@ -229,6 +220,32 @@ export class DownloadManager {
JSON.parse(JSON.stringify(payload)) JSON.parse(JSON.stringify(payload))
); );
} }
if (progress === 1 && this.game && !isDownloadingMetadata) {
await this.publishNotification();
await downloadQueueRepository.delete({ game: this.game });
/*
Only cancel bittorrent downloads to stop seeding
*/
if (status.bittorrent) {
await this.cancelDownload(this.game.id);
} else {
this.clearCurrentDownload();
}
const [nextQueueItem] = await downloadQueueRepository.find({
order: {
id: "DESC",
},
relations: {
game: true,
},
});
this.resumeDownload(nextQueueItem!.game);
}
} }
private static clearCurrentDownload() { private static clearCurrentDownload() {

View File

@ -1,30 +0,0 @@
import type { Repack } from "@main/entity";
import type { SteamGame } from "@types";
interface State {
repacks: Repack[];
steamGames: SteamGame[];
}
const initialState: State = {
repacks: [],
steamGames: [],
};
export class StateManager {
private state = initialState;
public setValue<T extends keyof State>(key: T, value: State[T]) {
this.state = { ...this.state, [key]: value };
}
public getValue<T extends keyof State>(key: T) {
return this.state[key];
}
public clearValue<T extends keyof State>(key: T) {
this.state = { ...this.state, [key]: initialState[key] };
}
}
export const stateManager = new StateManager();

View File

@ -7,7 +7,7 @@ import { formatName } from "@shared";
import { workerData } from "node:worker_threads"; import { workerData } from "node:worker_threads";
const steamGamesIndex = new flexSearch.Index({ const steamGamesIndex = new flexSearch.Index({
tokenize: "forward", tokenize: "reverse",
}); });
const { steamGamesPath } = workerData; const { steamGamesPath } = workerData;

View File

@ -40,6 +40,8 @@ export function Sidebar() {
updateLibrary(); updateLibrary();
}, [lastPacket?.game.id, updateLibrary]); }, [lastPacket?.game.id, updateLibrary]);
console.log(library);
const isDownloading = library.some( const isDownloading = library.some(
(game) => game.status === "active" && game.progress !== 1 (game) => game.status === "active" && game.progress !== 1
); );
@ -100,8 +102,6 @@ export function Sidebar() {
}, [isResizing]); }, [isResizing]);
const getGameTitle = (game: LibraryGame) => { const getGameTitle = (game: LibraryGame) => {
if (game.status === "paused") return t("paused", { title: game.title });
if (lastPacket?.game.id === game.id) { if (lastPacket?.game.id === game.id) {
return t("downloading", { return t("downloading", {
title: game.title, title: game.title,
@ -109,6 +109,12 @@ export function Sidebar() {
}); });
} }
if (game.downloadQueue !== null) {
return t("queued", { title: game.title });
}
if (game.status === "paused") return t("paused", { title: game.title });
return game.title; return game.title;
}; };

View File

@ -0,0 +1,119 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = recipe({
base: {
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
},
variants: {
cancelled: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const downloadGroup = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});

View File

@ -0,0 +1,246 @@
import { useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import { Badge, Button } from "@renderer/components";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-group.css";
import { useTranslation } from "react-i18next";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;
openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void;
}
export function DownloadGroup({
library,
title,
openDeleteGameModal,
openGameInstaller,
}: DownloadGroupProps) {
const navigate = useNavigate();
const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const {
lastPacket,
progress,
pauseDownload,
resumeDownload,
removeGameFromLibrary,
cancelDownload,
isGameDeleting,
} = useDownload();
const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
if (game.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
return "N/A";
};
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</>
);
}
if (game.progress === 1) {
return <p>{t("completed")}</p>;
}
if (game.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t(game.downloadQueue && lastPacket ? "queued" : "paused")}</p>
</>
);
}
if (game.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status)}</p>;
};
const getGameActions = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id);
if (game.progress === 1) {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
if (isGameDownloading || game.status === "active") {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
return (
<>
<Button
onClick={() => navigate(buildGameDetailsPath(game))}
theme="outline"
disabled={deleting}
>
{t("download_again")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
};
if (!library.length) return null;
return (
<div className={styles.downloadGroup}>
<h2>{title}</h2>
<ul className={styles.downloads}>
{library.map((game) => {
return (
<li
key={game.id}
className={styles.download({
cancelled: game.status === "removed",
})}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCoverImage}
alt={game.title}
/>
<div className={styles.downloadCoverContent}>
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
</div>
</div>
</div>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@ -1,117 +1,5 @@
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
marginBottom: `${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
});
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.body,
textAlign: "left",
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
marginTop: `${SPACING_UNIT * 3}px`,
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
zIndex: "1",
});
export const downloadCoverContent = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT}px`,
display: "flex",
alignItems: "flex-end",
justifyContent: "flex-end",
});
export const downloadCoverBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)",
display: "flex",
overflow: "hidden",
zIndex: "1",
});
export const downloadCoverImage = style({
width: "100%",
height: "100%",
position: "absolute",
zIndex: "-1",
});
export const download = recipe({
base: {
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
},
variants: {
cancelled: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const downloadsContainer = style({ export const downloadsContainer = style({
display: "flex", display: "flex",
@ -119,3 +7,9 @@ export const downloadsContainer = style({
flexDirection: "column", flexDirection: "column",
width: "100%", width: "100%",
}); });
export const downloadGroups = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
flexDirection: "column",
});

View File

@ -1,227 +1,96 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Badge, Button, TextField } from "@renderer/components"; import { useDownload, useLibrary } from "@renderer/hooks";
import {
buildGameDetailsPath,
formatDownloadProgress,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import type { LibraryGame } from "@types";
import { useEffect, useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css"; import * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal"; import { DeleteGameModal } from "./delete-game-modal";
import { Downloader, formatBytes } from "@shared"; import { DownloadGroup } from "./download-group";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { LibraryGame } from "@types";
import { orderBy } from "lodash-es";
export function Downloads() { export function Downloads() {
const { library, updateLibrary } = useLibrary(); const { library, updateLibrary } = useLibrary();
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const navigate = useNavigate();
const gameToBeDeleted = useRef<number | null>(null); const gameToBeDeleted = useRef<number | null>(null);
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false); const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const { const { removeGameInstaller } = useDownload();
lastPacket,
progress,
pauseDownload,
resumeDownload,
removeGameFromLibrary,
cancelDownload,
removeGameInstaller,
isGameDeleting,
} = useDownload();
const libraryWithDownloadedGamesOnly = useMemo(() => {
return library.filter((game) => game.status);
}, [library]);
useEffect(() => {
setFilteredLibrary(libraryWithDownloadedGamesOnly);
}, [libraryWithDownloadedGamesOnly]);
const openGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
if (game.fileSize) return formatBytes(game.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
return "N/A";
};
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
)}
</>
);
}
if (game.progress === 1) {
return <p>{t("completed")}</p>;
}
if (game.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t("paused")}</p>
</>
);
}
if (game.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status)}</p>;
};
const openDeleteModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
setShowDeleteModal(true);
};
const getGameActions = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id);
if (game.progress === 1) {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
if (isGameDownloading || game.status === "active") {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
return (
<>
<Button
onClick={() => navigate(buildGameDetailsPath(game))}
theme="outline"
disabled={deleting}
>
{t("download_again")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
libraryWithDownloadedGamesOnly.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
);
};
const handleDeleteGame = async () => { const handleDeleteGame = async () => {
if (gameToBeDeleted.current) if (gameToBeDeleted.current)
await removeGameInstaller(gameToBeDeleted.current); await removeGameInstaller(gameToBeDeleted.current);
}; };
const { lastPacket } = useDownload();
const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const handleOpenDeleteGameModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
setShowDeleteModal(true);
};
const libraryGroup: Record<string, LibraryGame[]> = useMemo(() => {
const initialValue: Record<string, LibraryGame[]> = {
downloading: [],
queued: [],
complete: [],
};
const result = library.reduce((prev, next) => {
if (lastPacket?.game.id === next.id) {
return { ...prev, downloading: [...prev.downloading, next] };
}
if (next.downloadQueue || next.status === "paused") {
return { ...prev, queued: [...prev.queued, next] };
}
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
const queued = orderBy(
result.queued,
(game) => game.downloadQueue?.id ?? -1,
["desc"]
);
const complete = orderBy(result.complete, (game) =>
game.status === "complete" ? 0 : 1
);
return {
...result,
queued,
complete,
};
}, [library, lastPacket?.game.id]);
const downloadGroups = [
{
title: t("download_in_progress"),
library: libraryGroup.downloading,
},
{
title: t("queued_downloads"),
library: libraryGroup.queued,
},
{
title: t("downloads_completed"),
library: libraryGroup.complete,
},
];
return ( return (
<section className={styles.downloadsContainer}> <section className={styles.downloadsContainer}>
<BinaryNotFoundModal <BinaryNotFoundModal
@ -235,53 +104,17 @@ export function Downloads() {
deleteGame={handleDeleteGame} deleteGame={handleDeleteGame}
/> />
<TextField placeholder={t("filter")} onChange={handleFilter} /> <div className={styles.downloadGroups}>
{downloadGroups.map((group) => (
<ul className={styles.downloads}> <DownloadGroup
{filteredLibrary.map((game) => { key={group.title}
return ( title={group.title}
<li library={group.library}
key={game.id} openDeleteGameModal={handleOpenDeleteGameModal}
className={styles.download({ openGameInstaller={handleOpenGameInstaller}
cancelled: game.status === "removed", />
})} ))}
> </div>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCoverImage}
alt={game.title}
/>
<div className={styles.downloadCoverContent}>
<Badge>{DOWNLOADER_NAME[game.downloader]}</Badge>
</div>
</div>
</div>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
>
{game.title}
</button>
</div>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
</section> </section>
); );
} }

View File

@ -37,7 +37,6 @@ export function AddDownloadSourceModal({
try { try {
const result = await window.electron.validateDownloadSource(value); const result = await window.electron.validateDownloadSource(value);
console.log(result);
setValidationResult(result); setValidationResult(result);
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@ -86,6 +86,12 @@ export interface CatalogueEntry {
repacks: GameRepack[]; repacks: GameRepack[];
} }
export interface DownloadQueue {
id: number;
createdAt: Date;
updatedAt: Date;
}
/* Used by the library */ /* Used by the library */
export interface Game { export interface Game {
id: number; id: number;
@ -104,6 +110,7 @@ export interface Game {
fileSize: number; fileSize: number;
objectID: string; objectID: string;
shop: GameShop; shop: GameShop;
downloadQueue: DownloadQueue | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }