Merge branch 'main' into linux-install

This commit is contained in:
Daniel Freitas 2024-05-12 10:23:42 -03:00 committed by GitHub
commit ac18b5388a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
98 changed files with 2317 additions and 1243 deletions

View File

@ -1,14 +0,0 @@
name: Contributors
on:
push:
branches: main
jobs:
contributors:
runs-on: ubuntu-latest
steps:
- uses: akhilmhdh/contributors-readme-action@v2.3.8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,8 +1,6 @@
name: Lint
on:
push:
branches: "**"
on: [pull_request, push]
jobs:
lint:

236
README.md
View File

@ -1,38 +1,34 @@
<br>
<div align="center">
<a href="https://hydralauncher.site">
<img src="./resources/icon.png" width="144"/>
</a>
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.</strong>
</p>
<p>
<a href="https://discord.gg/hydralauncher">
<img src ="https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c"/>
</a>
<a href="https://github.com/hydralauncher/hydra">
<img src="https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml" />
</a>
<a href="https://github.com/hydralauncher/hydra">
<img src="https://img.shields.io/github/package-json/v/hydralauncher/hydra" />
</a>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
![Hydra Catalogue](./docs/screenshot.png)
</div>
<br>
## Table of Contents
- [About](#about)
- [Features](#features)
- [Installation](#installation)
- [Contributing](#contributing)
- [Join our Discord](#join-our-discord)
- [Join our Telegram](#join-our-telegram)
- [Fork and clone your repository](#fork-and-clone-your-repository)
- [Ways you can contribute](#ways-you-can-contribute)
- [Project Structure](#project-structure)
@ -76,15 +72,11 @@ Follow the steps below to install:
2. Run the downloaded file.
3. Enjoy Hydra!
## Contributing
## <a name="contributing"> Contributing
### Join our Discord
### <a name="join-our-telegram"></a> Join our Telegram
We concentrate our discussions on our [Discord](https://discord.gg/hydralauncher) server.
1. Join our server
2. Go to the roles channel and grab the Collaborator role
3. Go to the dev channel, talk to us and share your ideas.
We concentrate our discussions on our [Telegram](https://t.me/hydralauncher) channel.
### Fork and clone your repository
@ -97,7 +89,7 @@ We concentrate our discussions on our [Discord](https://discord.gg/hydralauncher
### Ways you can contribute
- Translation: We want Hydra to be available to as many people as possible. Feel free to help translate to new languages or update and improve the ones that are already available on Hydra.
- Code: Hydra is built with Typescript, Electron and a little bit of Python. If you want to contribute, join our Discord server!
- Code: Hydra is built with Typescript, Electron and a little bit of Python. If you want to contribute, join our [Telegram](https://t.me/hydralauncher)!
### Project Structure
@ -179,197 +171,9 @@ yarn build:linux
## Contributors
<!-- readme: contributors -start -->
<table>
<tr>
<td align="center">
<a href="https://github.com/hydralauncher">
<img src="https://avatars.githubusercontent.com/u/164102380?v=4" width="100;" alt="hydralauncher"/>
<br />
<sub><b>Hydra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/zamitto">
<img src="https://avatars.githubusercontent.com/u/167933696?v=4" width="100;" alt="zamitto"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/fzanutto">
<img src="https://avatars.githubusercontent.com/u/15229294?v=4" width="100;" alt="fzanutto"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/JackEnx">
<img src="https://avatars.githubusercontent.com/u/167036558?v=4" width="100;" alt="JackEnx"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Magrid0">
<img src="https://avatars.githubusercontent.com/u/73496008?v=4" width="100;" alt="Magrid0"/>
<br />
<sub><b>Magrid</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/fhilipecrash">
<img src="https://avatars.githubusercontent.com/u/36455575?v=4" width="100;" alt="fhilipecrash"/>
<br />
<sub><b>Fhilipe Coelho</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/jps14">
<img src="https://avatars.githubusercontent.com/u/168477146?v=4" width="100;" alt="jps14"/>
<br />
<sub><b>José Luís</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/shadowtosser">
<img src="https://avatars.githubusercontent.com/u/168544958?v=4" width="100;" alt="shadowtosser"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Mkdantas">
<img src="https://avatars.githubusercontent.com/u/50972667?v=4" width="100;" alt="Mkdantas"/>
<br />
<sub><b>Matheus Dantas</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Hachi-R">
<img src="https://avatars.githubusercontent.com/u/58823742?v=4" width="100;" alt="Hachi-R"/>
<br />
<sub><b>Hachi</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/pmenta">
<img src="https://avatars.githubusercontent.com/u/71457671?v=4" width="100;" alt="pmenta"/>
<br />
<sub><b>João Martins</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/xbozo">
<img src="https://avatars.githubusercontent.com/u/119091492?v=4" width="100;" alt="xbozo"/>
<br />
<sub><b>Guilherme Viana</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/ferivoq">
<img src="https://avatars.githubusercontent.com/u/36544651?v=4" width="100;" alt="ferivoq"/>
<br />
<sub><b>FeriVOQ</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Tunchichi">
<img src="https://avatars.githubusercontent.com/u/118926729?v=4" width="100;" alt="Tunchichi"/>
<br />
<sub><b>Ruslan</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/eltociear">
<img src="https://avatars.githubusercontent.com/u/22633385?v=4" width="100;" alt="eltociear"/>
<br />
<sub><b>Ikko Eltociear Ashimine</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Netflixyapp">
<img src="https://avatars.githubusercontent.com/u/91623880?v=4" width="100;" alt="Netflixyapp"/>
<br />
<sub><b>Netflixy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/vnumex">
<img src="https://avatars.githubusercontent.com/u/10434535?v=4" width="100;" alt="vnumex"/>
<br />
<sub><b>Vnumex</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/FerNikoMF">
<img src="https://avatars.githubusercontent.com/u/76095334?v=4" width="100;" alt="FerNikoMF"/>
<br />
<sub><b>Firdavs</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/PCTroller">
<img src="https://avatars.githubusercontent.com/u/146987801?v=4" width="100;" alt="PCTroller"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/AHOHNMYC">
<img src="https://avatars.githubusercontent.com/u/24810600?v=4" width="100;" alt="AHOHNMYC"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Chr1s0Blood">
<img src="https://avatars.githubusercontent.com/u/166660500?v=4" width="100;" alt="Chr1s0Blood"/>
<br />
<sub><b>Cristian S.</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ChristoferMendes">
<img src="https://avatars.githubusercontent.com/u/107426464?v=4" width="100;" alt="ChristoferMendes"/>
<br />
<sub><b>Christofer Luiz Dos Santos Mendes</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/IWareQ">
<img src="https://avatars.githubusercontent.com/u/51165317?v=4" width="100;" alt="IWareQ"/>
<br />
<sub><b>Dmitry Luk</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/userMacieG">
<img src="https://avatars.githubusercontent.com/u/24211405?v=4" width="100;" alt="userMacieG"/>
<br />
<sub><b>Maciej Ratyński</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/HOLKus">
<img src="https://avatars.githubusercontent.com/u/66418078?v=4" width="100;" alt="HOLKus"/>
<br />
<sub><b>Redulum</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/cardosource">
<img src="https://avatars.githubusercontent.com/u/29387672?v=4" width="100;" alt="cardosource"/>
<br />
<sub><b>Cardoso</b></sub>
</a>
</td></tr>
</table>
<!-- readme: contributors -end -->
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## License

180
README.pt-BR.md Normal file
View File

@ -0,0 +1,180 @@
<br>
<div align="center">
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado e um wrapper autogerenciado para busca de repacks.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
![Hydra Catalogue](./docs/screenshot.png)
</div>
## Índice
- [Sobre](#about)
- [Recursos](#features)
- [Instalação](#installation)
- [Contribuindo](#contributing)
- [Junte-se ao nosso Telegram](#join-our-telegram)
- [Fork e clone seu repositorio](#fork-and-clone-your-repository)
- [Como contribuir](#ways-you-can-contribute)
- [Estrutura do projeto](#project-structure)
- [Compile a partir do código-fonte](#build-from-source)
- [Instale Node.js](#install-nodejs)
- [Instale Yarn](#install-yarn)
- [Instale Node Dependencies](#install-node-dependencies)
- [Instale Python 3.9](#install-python-39)
- [Instale Python Dependencies](#install-python-dependencies)
- [variaveis de ambiente](#environment-variables)
- [Rodando o programa](#running)
- [Compilando](#build)
- [Compile o client bittorrent](#build-the-bittorrent-client)
- [Compile a aplicação Electron](#build-the-electron-application)
- [Contribuidores](#contributors)
## <a name="about"> Sobre
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado** e um **raspador de repack auto-gerenciado**.
<br>
O launcher é escrito em TypeScript (Electron) e Python, que lida com o sistema de torrent usando libtorrent.
## <a name="features"> Recursos
- Wrapper de repacks auto-gerenciado entre todos os sites mais confiáveis no [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Cliente BitTorrent incorporado próprio
- Integração com [How Long To Beat (HLTB)](https://howlongtobeat.com/) na página do jogo
- Personalização do caminho de downloads
- Notificações de atualização da lista de repacks
- Suporte para Windows e Linux
- Constantemente atualizado
- E mais ...
## <a name="installation"> Instalação
Siga os passos abaixo para instalar:
1. Baixe a versão mais recente do Hydra na página de [Releases](https://github.com/hydralauncher/hydra/releases/latest).
- Baixe apenas o .exe se quiser instalar o Hydra no Windows.
- Baixe .deb ou .rpm ou .zip se quiser instalar o Hydra no Linux. (depende da sua distribuição Linux)
2. Execute o arquivo baixado.
3. Aproveite o Hydra!
## <a name="contributing"> Contribuindo
### <a name="join-our-telegram"></a> Junte-se ao nosso Telegram
Concentramos nossas discussões no nosso canal do [Telegram](https://t.me/hydralauncher).
### <a name="fork-and-clone-your-repository"></a> Fork e clone o seu repositório
1. Faça um fork do repositório [(clique aqui para fazer o fork agora)](https://github.com/hydralauncher/hydra/fork)
2. Clone o código do seu fork `git clone https://github.com/seu_nome_de_usuário/hydra`
3. Crie uma nova branch
4. Faça o push dos seus commits
5. Envie um novo Pull Request
### <a name="ways-you-can-contribute"></a> Formas de contribuir
- **Tradução**: Queremos que o Hydra esteja disponível para o maior número possível de pessoas. Sinta-se à vontade para ajudar a traduzir para novos idiomas ou atualizar e melhorar aqueles que já estão disponíveis no Hydra.
- **Código**: O Hydra é construído com Typescript, Electron e um pouco de Python. Se você deseja contribuir, junte-se ao nosso [Telegram](https://t.me/hydralauncher)!
### <a name="project-structure"></a> Estrutura do Projeto
- torrent-client: Utilizamos o libtorrent, uma biblioteca Python, para gerenciar downloads via torrent.
- src/renderer: A interface de usuário (UI) da aplicação.
- src/main: Toda a lógica da aplicação reside aqui.
## <a name="build-from-source"></a> Compile a partir do código-fonte
### <a name="install-nodejs"></a> Instale Node.js
Certifique-se de ter o Node.js instalado em sua máquina. Se não, faça o download e instale-o em [nodejs.org](https://nodejs.org/).
### <a name="install-yarn"></a> Instale Yarn
Yarn é um gerenciador de pacotes para Node.js. Se você ainda não o instalou, pode fazê-lo seguindo as instruções em [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
### <a name="install-node-dependencies"></a> Instale Dependencias do Node
Navegue até o diretório do projeto e instale as dependências do Node usando o Yarn:
```bash
cd hydra
yarn
```
### <a name="install-python-39"></a> Instale Python 3.9
Certifique-se de ter o Python 3.9 instalado em sua máquina. Você pode baixá-lo e instalá-lo em [python.org](https://www.python.org/downloads/release/python-3919/).
### <a name="install-python-dependencies"></a> Instale Python Dependencies
Instale as dependências Python necessárias usando o pip:
```bash
pip install -r requirements.txt
```
## <a name="environment-variables"></a> Environment variables
Você precisará de uma chave da API SteamGridDB para buscar os ícones do jogo durante a instalação.
Se você deseja ter o onlinefix como um repacker, precisará adicionar suas credenciais ao arquivo .env.
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME` e `ONLINEFIX_PASSWORD`.
## <a name="running"></a> Running
Uma vez que você tenha configurado tudo, você pode executar o seguinte comando para iniciar tanto o processo Electron quanto o cliente BitTorrent:
```bash
yarn dev
```
## <a name="build"></a> Build
### <a name="build-the-bittorrent-client"></a> Build the bittorrent client
Compile o cliente BitTorrent usando este comando
```bash
python torrent-client/setup.py build
```
### <a name="build-the-electron-application"></a> Build the Electron application
Compile a aplicação Electron usando este comando:
No Windows:
```bash
yarn build:win
```
No Linux:
```bash
yarn build:linux
```
## <a name="contributors"></a> Contributors
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## <a name="license"></a> Licença
O Hydra é licenciado sob a [Licença MIT](LICENSE).

View File

@ -1,41 +1,34 @@
<br>
<div align="center">
<a href="https://hydralauncher.site">
<img src="./resources/icon.png" width="144"/>
</a>
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent и самостоятельным scraper`ом для репаков.</strong>
</p>
<p>
<a href="https://discord.gg/hydralauncher">
<img src ="https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c"/>
</a>
<a href="https://github.com/hydralauncher/hydra">
<img src="https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml" />
</a>
<a href="https://github.com/hydralauncher/hydra">
<img src="https://img.shields.io/github/package-json/v/hydralauncher/hydra" />
</a>
</p>
![Hydra Catalogue](./docs/screenshot.png)
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
![Hydra Catalogue](./docs/screenshot.png)
</div>
<br>
### Язык
[![ru](https://img.shields.io/badge/lang-ru-red)](https://github.com/hydralauncher/hydra/blob/main/README.ru.md)
## Содержание
- [Описание](#описание)
- [Особенности](#особенности)
- [Установка](#установка)
- [Сотрудничество](#сотрудничество)
- [Присоединяйтесь к нашему Discord](#присоединяйтесь-к-нашему-discord)
- [Вклад](#contributing)
- [Присоединяйтесь к нашему Telegram](#join-our-telegram)
- [Форк и клонирование репозитория](#форк-и-клонирование-репозитория)
- [Способы внести свой вклад](#способы-внести-свой-вклад)
- [Структура проекта](#структура-проекта)
@ -79,15 +72,11 @@
2. Запустите скачанный файл.
3. Наслаждайтесь Hydra!
## Сотрудничество
## <a name="contributing"> Вклад
### Присоединяйтесь к нашему Discord
### <a name="join-our-telegram"></a> Присоединяйтесь к нашему Telegram
Мы сосредотачиваем наши обсуждения на нашем [Discord](https://discord.gg/hydralauncher) сервере.
1. Присоединитесь к нашему серверу.
2. Перейдите в канал ролей и получите роль Collaborator.
3. Перейдите в канал Dev, общайтесь с нами и делитесь своими идеями.
Мы сосредотачиваем наши обсуждения в нашем канале [Telegram](https://t.me/hydralauncher).
### Форк и клонирование репозитория
@ -100,7 +89,7 @@
### Способы внести свой вклад
- Перевод: Мы хотим, чтобы Hydra была доступна как можно большему количеству людей. Не стесняйтесь помогать переводить на новые языки или обновлять и улучшать те, которые уже доступны в Hydra.
- Код: Hydra создан с использованием TypeScript, Electron и немного Python. Если хотите внести свой вклад, присоединяйтесь к нашему серверу Discord!
- Код: Hydra создан с использованием TypeScript, Electron и немного Python. Если хотите внести свой вклад, присоединяйтесь к нашему серверу [Telegram](https://t.me/hydralauncher)!
### Структура проекта
@ -182,132 +171,9 @@ yarn build:linux
## Участники
<!-- readme: contributors -start -->
<table>
<tr>
<td align="center">
<a href="https://github.com/hydralauncher">
<img src="https://avatars.githubusercontent.com/u/164102380?v=4" width="100;" alt="hydralauncher"/>
<br />
<sub><b>Hydra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/zamitto">
<img src="https://avatars.githubusercontent.com/u/167933696?v=4" width="100;" alt="zamitto"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/fzanutto">
<img src="https://avatars.githubusercontent.com/u/15229294?v=4" width="100;" alt="fzanutto"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/JackEnx">
<img src="https://avatars.githubusercontent.com/u/167036558?v=4" width="100;" alt="JackEnx"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Magrid0">
<img src="https://avatars.githubusercontent.com/u/73496008?v=4" width="100;" alt="Magrid0"/>
<br />
<sub><b>Magrid</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/fhilipecrash">
<img src="https://avatars.githubusercontent.com/u/36455575?v=4" width="100;" alt="fhilipecrash"/>
<br />
<sub><b>Fhilipe Coelho</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/jps14">
<img src="https://avatars.githubusercontent.com/u/168477146?v=4" width="100;" alt="jps14"/>
<br />
<sub><b>José Luís</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/shadowtosser">
<img src="https://avatars.githubusercontent.com/u/168544958?v=4" width="100;" alt="shadowtosser"/>
<br />
<sub><b>Null</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/pmenta">
<img src="https://avatars.githubusercontent.com/u/71457671?v=4" width="100;" alt="pmenta"/>
<br />
<sub><b>João Martins</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ferivoq">
<img src="https://avatars.githubusercontent.com/u/36544651?v=4" width="100;" alt="ferivoq"/>
<br />
<sub><b>FeriVOQ</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/xbozo">
<img src="https://avatars.githubusercontent.com/u/119091492?v=4" width="100;" alt="xbozo"/>
<br />
<sub><b>Guilherme Viana</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/eltociear">
<img src="https://avatars.githubusercontent.com/u/22633385?v=4" width="100;" alt="eltociear"/>
<br />
<sub><b>Ikko Eltociear Ashimine</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/Netflixyapp">
<img src="https://avatars.githubusercontent.com/u/91623880?v=4" width="100;" alt="Netflixyapp"/>
<br />
<sub><b>Netflixy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Hachi-R">
<img src="https://avatars.githubusercontent.com/u/58823742?v=4" width="100;" alt="Hachi-R"/>
<br />
<sub><b>Hachi</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/FerNikoMF">
<img src="https://avatars.githubusercontent.com/u/76095334?v=4" width="100;" alt="FerNikoMF"/>
<br />
<sub><b>Firdavs</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/userMacieG">
<img src="https://avatars.githubusercontent.com/u/24211405?v=4" width="100;" alt="userMacieG"/>
<br />
<sub><b>Maciej Ratyński</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Tunchichi">
<img src="https://avatars.githubusercontent.com/u/118926729?v=4" width="100;" alt="Tunchichi"/>
<br />
<sub><b>Ruslan</b></sub>
</a>
</td></tr>
</table>
<!-- readme: contributors -end -->
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## License

184
README.uk-UA.md Normal file
View File

@ -0,0 +1,184 @@
<br>
<div align="center">
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом і самокерованим збирачем репаків.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
![Hydra Catalogue](./docs/screenshot.png)
</div>
## Зміст
- [Про нас](#про-нас)
- [Функції](#функції)
- [Встановлення](#встановлення)
- [Зробити свій внесок](#contributing)
- [Приєднуйтесь до нашого Telegram](#join-our-telegram)
- [Форк і клонування вашого репозиторію](#форк-і-клонування-вашого-репозиторію)
- [Як ви можете зробити свій внесок](#як-ви-можете-зробити-свій-внесок)
- [Структура проекту](#структура-проекту)
- [Зробити білд з вихідного коду](#зробити-білд-з-вихідного-коду)
- [Встановіть Node.js](#встановіть-nodejs)
- [Встановіть Yarn](#встановіть-yarn)
- [Встановіть Node залежності](#встановіть-node-залежності)
- [Встановіть Python 3.9](#встановіть-python-39)
- [Встановіть Python залежності](#встановіть-python-залежності)
- [Змінні середовища](#змінні-середовища)
- [Запустіть](#запустіть)
- [Зробіть білд](#зробіть-білд)
- [Зробіть білд bittorrent client](#зробіть-білд-bittorrent-client)
- [Зробіть білд Electron застосунку](#зробіть-білд-electron-застосунку)
- [Контриб'ютори](#контрибютори)
## Про нас
**Hydra** - це **ігровий лаунчер** з власним вбудованим **BitTorrent-клієнтом** і **самокерованим збирачем репаків**.
<br>
Цей лаунчер написано мовами TypeScript (Electron) та Python, який працює з торрент-системою за допомогою libtorrent.
## Функції
- Самокерований збирач репаків серед усіх найнадійніших сайтів на [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Власний вбудований клієнт bittorrent
- Інтеграція How Long To Beat (HLTB) на сторінці гри
- Налаштування теки завантаження
- Сповіщення про оновлення списку репаків
- Підтримка Windows і Linux
- Постійно оновлюється
- І не тільки ...
## Встановлення
Щоб встановити, виконайте наведені нижче кроки:
1. Завантажте останню версію Hydra зі сторінки [Releases](https://github.com/hydralauncher/hydra/releases/latest).
- Завантажте лише .exe, якщо ви хочете встановити Hydra на Windows.
- Завантажте .deb або .rpm або .zip, якщо ви хочете встановити Hydra на Linux. (залежить від вашого дистрибутива Linux)
2. Запустіть завантажений файл.
3. Насолоджуйтесь Гідрою!
## <a name="contributing"> Зробити свій внесок
### <a name="join-our-telegram"></a> Приєднуйтесь до нашого Telegram
Ми зосереджуємо наші дискусії на нашому сервері [Telegram](https://t.me/hydralauncher).
1. Приєднуйтесь до нашого сервера
2. Перейдіть на канал ролей і виберіть роль Співробітник
3. Заходьте на dev-канал, спілкуйтеся з нами та діліться своїми ідеями.
### Форк і клонування вашого репозиторію
1. Зробіть форк репозиторію [(натисніть тут, щоб зробити форк зараз)](https://github.com/hydralauncher/hydra/fork)
2. Клонуйте ваш форк-код `git clone https://github.com/your_username/hydra`
3. Створіть новий бранч
4. Зробіть пуш своїх комітів
5. Надішліть новий Pull Request
### Як ви можете зробити свій внесок
- Переклад: Ми хочемо, щоб Hydra була доступна якомога більшій кількості людей. Не соромтеся допомагати перекладати на нові мови або оновлювати і покращувати ті, які вже доступні на Hydra.
- Код: Hydra створена за допомогою Typescript, Electron і трохи Python. Якщо ви хочете зробити свій внесок, приєднуйтесь до нашого Telegram!
### Структура проекту
- torrent-client: Ми використовуємо libtorrent, бібліотеку Python, для керування завантаженнями з торрентів
- src/renderer: інтерфейс програми
- src/main: вся логіка тут.
## Зробити білд з вихідного коду
### Встановіть Node.js
Переконайтеся, що на вашому комп'ютері встановлено Node.js. Якщо ні, завантажте та встановіть його з [nodejs.org](https://nodejs.org/).
### Встановіть Yarn
Yarn - це менеджер пакетів для Node.js. Якщо ви ще не встановили Yarn, ви можете зробити це, дотримуючись інструкцій на сторінці [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
### Встановіть Node залежності
Перейдіть до каталогу проекту і встановіть Node залежності за допомогою Yarn:
```bash
cd hydra
yarn
```
### Встановіть Python 3.9
Переконайтеся, що на вашому комп'ютері встановлено Python 3.9. Ви можете завантажити та встановити його з [python.org](https://www.python.org/downloads/release/python-3919/).
### Встановіть Python залежності
Встановіть необхідні залежності Python за допомогою pip:
```bash
pip install -r requirements.txt
```
## Змінні середовища
Вам знадобиться ключ API SteamGridDB, щоб отримати іконки ігор під час встановлення.
Якщо ви хочете використовувати onlinefix як перепакувальник, вам потрібно додати свої облікові дані до .env
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
## Запустіть
Після того, як ви все налаштували, ви можете запустити наступну команду, щоб запустити як процес Electron, так і клієнт bittorrent:
```bash
yarn dev
```
## Зробіть білд
### Зробіть білд bittorrent client
Зробіть білд bittorrent client за допомогою цієї команди:
```bash
python torrent-client/setup.py build
```
### Зробіть білд Electron застосунку
Зробіть білд Electron застосунку за допомогою цієї команди:
На Windows:
```bash
yarn build:win
```
На Linux:
```bash
yarn build:linux
```
## Контриб'ютори
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## License
Hydra має ліцензію [MIT License](LICENSE).

View File

@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => {
"@main": resolve("src/main"),
"@locales": resolve("src/locales"),
"@resources": resolve("resources"),
"@shared": resolve("src/shared"),
},
},
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
@ -46,6 +47,7 @@ export default defineConfig(({ mode }) => {
alias: {
"@renderer": resolve("src/renderer/src"),
"@locales": resolve("src/locales"),
"@shared": resolve("src/shared"),
},
},
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],

View File

@ -42,8 +42,10 @@
"better-sqlite3": "^9.5.0",
"check-disk-space": "^3.4.0",
"classnames": "^2.5.1",
"color": "^4.2.3",
"color.js": "^1.2.0",
"date-fns": "^3.6.0",
"easydl": "^1.1.1",
"fetch-cookie": "^3.0.1",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
@ -51,6 +53,7 @@
"jsdom": "^24.0.0",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"node-7z-archive": "^1.1.7",
"parse-torrent": "^11.0.16",
"ps-list": "^8.1.1",
"react-i18next": "^14.1.0",

View File

@ -19,6 +19,7 @@
"follow_us": "Падпісвайцеся на нас",
"home": "Галоўная",
"discord": "Далучайцеся да Discord",
"telegram": "Далучайцеся да Telegram",
"x": "Падпісвайцеся на X",
"github": "Зрабіць свой унёсак на GitHub"
},

View File

@ -19,11 +19,12 @@
"follow_us": "Follow us",
"home": "Home",
"discord": "Join our Discord",
"telegram": "Join our Telegram",
"x": "Follow on X",
"github": "Contribute on GitHub"
},
"header": {
"search": "Search",
"search": "Search games",
"home": "Home",
"catalogue": "Catalogue",
"downloads": "Downloads",
@ -86,8 +87,7 @@
"change": "Change",
"repacks_modal_description": "Choose the repack you want to download",
"downloads_path": "Downloads path",
"select_folder_hint": "To change the default folder, access the",
"settings": "Settings",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now",
"installation_instructions": "Installation Instructions",
"installation_instructions_description": "Additional steps are required to install this game",
@ -127,7 +127,9 @@
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install"
"install": "Install",
"real_debrid": "Real Debrid",
"torrent": "Torrent"
},
"settings": {
"downloads_path": "Downloads path",
@ -137,9 +139,15 @@
"enable_repack_list_notifications": "When a new repack is added",
"telemetry": "Telemetry",
"telemetry_description": "Enable anonymous usage statistics",
"real_debrid_api_token_description": "Real Debrid API token",
"quit_app_instead_hiding": "Quit Hydra instead of minimizing to tray",
"launch_with_system": "Launch Hydra on system start-up",
"general": "General",
"behavior": "Behavior",
"quit_app_instead_hiding": "Close app instead of minimizing to tray",
"launch_with_system": "Launch app on system start-up"
"enable_real_debrid": "Enable Real Debrid",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "You can get your API key <0>here</0>.",
"save_changes": "Save changes"
},
"notifications": {
"download_complete": "Download complete",

View File

@ -19,6 +19,7 @@
"follow_us": "Síguenos",
"home": "Inicio",
"discord": "Únete a nuestro Discord",
"telegram": "Únete a nuestro Telegram",
"x": "Síguenos en X",
"github": "Contribuye en GitHub"
},

View File

@ -8,3 +8,4 @@ export { default as pl } from "./pl/translation.json";
export { default as ru } from "./ru/translation.json";
export { default as tr } from "./tr/translation.json";
export { default as be } from "./be/translation.json";
export { default as uk } from "./uk/translation.json";

View File

@ -19,6 +19,7 @@
"follow_us": "Seguici",
"home": "Home",
"discord": "Unisciti al nostro Discord",
"telegram": "Unisciti al nostro Telegram",
"x": "Segui su X",
"github": "Contribuisci su GitHub"
},

View File

@ -19,11 +19,12 @@
"home": "Início",
"follow_us": "Acompanhe-nos",
"discord": "Entre no nosso Discord",
"telegram": "Entre no nosso Telegram",
"x": "Siga-nos no X",
"github": "Contribua no GitHub"
},
"header": {
"search": "Buscar",
"search": "Buscar jogos",
"catalogue": "Catálogo",
"downloads": "Downloads",
"search_results": "Resultados da busca",
@ -82,8 +83,7 @@
"change": "Mudar",
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"downloads_path": "Diretório do download",
"select_folder_hint": "Para trocar a pasta padrão, acesse as ",
"settings": "Configurações do Hydra",
"select_folder_hint": "Para trocar a pasta padrão, acesse a <0>Tela de Configurações</0>",
"download_now": "Baixe agora",
"installation_instructions": "Instruções de Instalação",
"installation_instructions_description": "Passos adicionais são necessários para instalar esse jogo",
@ -133,9 +133,14 @@
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"telemetry": "Telemetria",
"telemetry_description": "Habilitar estatísticas de uso anônimas",
"behavior": "Comportamento",
"quit_app_instead_hiding": "Fechar o aplicativo em vez de minimizá-lo",
"launch_with_system": "Iniciar aplicativo na inicialização do sistema"
"launch_with_system": "Iniciar aplicativo na inicialização do sistema",
"general": "Geral",
"behavior": "Comportamento",
"enable_real_debrid": "Habilitar Real Debrid",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "Você pode obter sua chave de API <0>aqui</0>.",
"save_changes": "Salvar mudanças"
},
"notifications": {
"download_complete": "Download concluído",

View File

@ -1,16 +1,16 @@
{
"home": {
"featured": "Рекомендованное",
"recently_added": "Недавно добавленное",
"trending": "Актуальное",
"recently_added": "Новинки",
"trending": "В тренде",
"surprise_me": "Удиви меня",
"no_results": "Результатов не найдено"
"no_results": "Ничего не найдено"
},
"sidebar": {
"catalogue": "Каталог",
"downloads": "Загрузки",
"settings": "Настройки",
"my_library": "Моя библиотека",
"my_library": "Библиотека",
"downloading_metadata": "{{title}} (Загрузка метаданных…)",
"checking_files": "{{title}} ({{percentage}} - Проверка файлов…)",
"paused": "{{title}} (Приостановлено)",
@ -18,9 +18,10 @@
"filter": "Фильтр библиотеки",
"follow_us": "Подписывайтесь на нас",
"home": "Главная",
"discord": "Присоединяйся к Discord",
"discord": "Присоединяйтесь к Discord",
"telegram": "Присоединяйтесь к Telegram",
"x": "Подписывайтесь на X",
"github": "Внести свой вклад в GitHub"
"github": "Внести свой вклад на GitHub"
},
"header": {
"search": "Поиск",
@ -41,7 +42,7 @@
"previous_page": "Предыдущая страница"
},
"game_details": {
"open_download_options": "Открыть опции загрузки",
"open_download_options": "Открыть варианты загрузки",
"download_options_zero": "Нет вариантов загрузки",
"download_options_one": "{{count}} вариант загрузки",
"download_options_other": "{{count}} вариантов загрузки",
@ -52,7 +53,7 @@
"cancel": "Отменить",
"remove": "Удалить",
"remove_from_list": "Удалить",
"space_left_on_disk": "{{space}} осталось на диске",
"space_left_on_disk": "{{space}} свободно на диске",
"eta": "Окончание {{eta}}",
"downloading_metadata": "Загрузка метаданных…",
"checking_files": "Проверка файлов…",
@ -65,35 +66,35 @@
"paused_progress": "{{progress}} (Приостановлено)",
"release_date": "Выпущено {{date}}",
"publisher": "Издатель {{publisher}}",
"copy_link_to_clipboard": "Скопировать ссылку",
"copy_link_to_clipboard": "Копировать ссылку",
"copied_link_to_clipboard": "Ссылка скопирована",
"hours": "часов",
"minutes": "минут",
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"accuracy": "{{accuracy}}% точность",
"accuracy": "точность {{accuracy}}%",
"add_to_library": "Добавить в библиотеку",
"remove_from_library": "Удалить из библиотеки",
"no_downloads": "Нет доступных загрузок",
"play_time": "Сыграно {{amount}}",
"last_time_played": "Последний раз сыграно {{period}}",
"last_time_played": "Последний запуск {{period}}",
"not_played_yet": "Вы ещё не играли в {{title}}",
"next_suggestion": "Следующее предложение",
"play": "Играть",
"deleting": "Удаление установщика…",
"close": "Закрыть",
"playing_now": "Текущая игра",
"playing_now": "Запущено",
"change": "Изменить",
"repacks_modal_description": "Выберите репак, который хотите загрузить",
"repacks_modal_description": "Выберите репак для загрузки",
"downloads_path": "Путь загрузок",
"select_folder_hint": "Чтобы изменить папку по умолчанию, откройте",
"select_folder_hint": "Изменить папку по умолчанию",
"settings": "Настройки Hydra",
"download_now": "Загрузить сейчас",
"installation_instructions": "Инструкция по установке",
"installation_instructions_description": "Для установки этой игры требуются дополнительные шаги",
"online_fix_instruction": "В играх с OnlineFix требуется ввести пароль для извлечения. При необходимости используйте следующий пароль:",
"dodi_installation_instruction": "Когда вы откроете программу установки DODI, нажмите на клавиатуре клавишу 'вверх' <0 />, чтобы начать процесс установки:",
"dont_show_it_again": "Не показывать это снова",
"dodi_installation_instruction": "Когда вы откроете установщик DODI, нажмите на клавиатуре клавишу 'вверх' <0 />, чтобы начать процесс установки:",
"dont_show_it_again": "Не показывать снова",
"copy_to_clipboard": "Копировать",
"copied_to_clipboard": "Скопировано",
"got_it": "Понятно"
@ -126,20 +127,20 @@
"delete": "Удалить установщик",
"remove_from_list": "Удалить",
"delete_modal_title": "Вы уверены?",
"delete_modal_description": "Это удалит все установочные файлы с вашего компьютера",
"delete_modal_description": "Это удалит все установщики с вашего компьютера",
"install": "Установить"
},
"settings": {
"downloads_path": "Путь загрузок",
"change": "Изменить путь",
"change": "Изменить",
"notifications": "Уведомления",
"enable_download_notifications": "По завершении загрузки",
"enable_repack_list_notifications": "При добавлении нового репака",
"telemetry": "Телеметрия",
"telemetry_description": "Отправлять анонимную статистику использования",
"behavior": "Поведение",
"quit_app_instead_hiding": "Закрывать приложение вместо того, чтобы сворачивать его в трей",
"launch_with_system": "Запуск приложения при запуске системы"
"quit_app_instead_hiding": "Закрывать Hydra вместо того, чтобы сворачивать его в трей",
"launch_with_system": "Запуск Hydra вместе с системой"
},
"notifications": {
"download_complete": "Загрузка завершена",
@ -157,10 +158,10 @@
},
"binary_not_found_modal": {
"title": "Программы не установлены",
"description": "Исполняемые файлы Wine или Lutris не найдены на вашей системе",
"instructions": "Узнайте правильный способ установить любой из них в ваш дистрибутив Linux, чтобы игра могла нормально работать"
"description": "Wine или Lutris не найдены",
"instructions": "Узнайте правильный способ установить любой из них на ваш дистрибутив Linux, чтобы игра могла нормально работать"
},
"modal": {
"close": "Кнопка закрытия"
"close": "Закрыть"
}
}

View File

@ -19,6 +19,7 @@
"follow_us": "Bizi takip et",
"home": "Ana menü",
"discord": "Discord'umuza katıl",
"telegram": "Telegram'umuza katıl",
"x": "X'te bizi takip et",
"github": "GitHub'da bize katkı yap"
},

View File

@ -0,0 +1,167 @@
{
"home": {
"featured": "Рекомендоване",
"recently_added": "Нове",
"trending": "У тренді",
"surprise_me": "Здивуй мене",
"no_results": "Результатів не знайдено"
},
"sidebar": {
"catalogue": "Каталог",
"downloads": "Завантаження",
"settings": "Налаштування",
"my_library": "Бібліотека",
"downloading_metadata": "{{title}} (Завантаження метаданих…)",
"checking_files": "{{title}} ({{percentage}} - Перевірка файлів…)",
"paused": "{{title}} (Призупинено)",
"downloading": "{{title}} ({{percentage}} - Завантаження…)",
"filter": "Фільтр бібліотеки",
"follow_us": "Підписуйтесь на нас",
"home": "Головна",
"discord": "Приєднуйтесь до Discord",
"telegram": "Приєднуйтесь до Telegram",
"x": "Підписуйтесь на X",
"github": "Зробіть свій внесок на GitHub"
},
"header": {
"search": "Пошук",
"home": "Головна",
"catalogue": "Каталог",
"downloads": "Завантаження",
"search_results": "Результати пошуку",
"settings": "Налаштування"
},
"bottom_panel": {
"no_downloads_in_progress": "Немає активних завантажень",
"downloading_metadata": "Завантаження метаданих {{title}}…",
"checking_files": "Перевірка файлів {{title}}… ({{percentage}} завершено)",
"downloading": "Завантаження {{title}}… ({{percentage}} завершено) - Закінчення {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Наступна сторінка",
"previous_page": "Попередня сторінка"
},
"game_details": {
"open_download_options": "Відкрити варіанти завантаження",
"download_options_zero": "Немає варіантів завантаження",
"download_options_one": "{{count}} варіант завантаження",
"download_options_other": "{{count}} варіантів завантаження",
"updated_at": "Оновлено {{updated_at}}",
"install": "Встановити",
"resume": "Відновити",
"pause": "Призупинити",
"cancel": "Скасувати",
"remove": "Видалити",
"remove_from_list": "Видалити",
"space_left_on_disk": "{{space}} вільно на диску",
"eta": "Закінчення {{eta}}",
"downloading_metadata": "Завантаження метаданих…",
"checking_files": "Перевірка файлів…",
"filter": "Фільтр репаків",
"requirements": "Системні вимоги",
"minimum": "Мінімальні",
"recommended": "Рекомендовані",
"no_minimum_requirements": "Для {{title}} не вказані мінімальні вимоги",
"no_recommended_requirements": "Для {{title}} не вказані рекомендовані вимоги",
"paused_progress": "{{progress}} (Призупинено)",
"release_date": "Випущено {{date}}",
"publisher": "Видавець {{publisher}}",
"copy_link_to_clipboard": "Скопіювати посилання",
"copied_link_to_clipboard": "Посилання скопійовано",
"hours": "годин",
"minutes": "хвилин",
"amount_hours": "{{amount}} годин",
"amount_minutes": "{{amount}} хвилин",
"accuracy": "{{accuracy}}% точність",
"add_to_library": "Додати до бібліотеки",
"remove_from_library": "Видалити з бібліотеки",
"no_downloads": "Немає доступних завантажень",
"play_time": "Час гри: {{amount}}",
"last_time_played": "Востаннє зіграно: {{period}}",
"not_played_yet": "Ви ще не грали в {{title}}",
"next_suggestion": "Наступна пропозиція",
"play": "Грати",
"deleting": "Видалення інсталятора…",
"close": "Закрити",
"playing_now": "Поточна гра",
"change": "Змінити",
"repacks_modal_description": "Виберіть репак, який хочете завантажити",
"downloads_path": "Шлях завантажень",
"select_folder_hint": "Щоб змінити теку за замовчуванням, відкрийте",
"settings": "Налаштування Hydra",
"download_now": "Завантажити зараз",
"installation_instructions": "Інструкція зі встановлення",
"installation_instructions_description": "Для встановлення цієї гри потрібні додаткові кроки",
"online_fix_instruction": "В іграх з OnlineFix потрібно ввести пароль для вилучення. За необхідності використовуйте наступний пароль:",
"dodi_installation_instruction": "Коли ви відкриєте інсталятор DODI, натисніть на клавіатурі клавішу 'вгору' <0 />, щоб почати процес встановлення:",
"dont_show_it_again": "Не показувати це знову",
"copy_to_clipboard": "Копіювати",
"copied_to_clipboard": "Скопійовано",
"got_it": "Зрозуміло"
},
"activation": {
"title": "Активувати Hydra",
"installation_id": "ID установки:",
"enter_activation_code": "Введіть ваш активаційний код",
"message": "Якщо ви не знаєте, де його запросити, то не повинні мати цього.",
"activate": "Активувати",
"loading": "Завантаження…"
},
"downloads": {
"resume": "Продовжити",
"pause": "Призупинити",
"eta": "Закінчення {{eta}}",
"paused": "Призупинено",
"verifying": "Перевірка…",
"completed_at": "Завершено в {{date}}",
"completed": "Завершено",
"cancelled": "Скасовано",
"download_again": "Завантажити знову",
"cancel": "Скасувати",
"filter": "Фільтр завантажених ігор",
"remove": "Видалити",
"downloading_metadata": "Завантаження метаданих…",
"checking_files": "Перевірка файлів…",
"starting_download": "Початок завантаження…",
"deleting": "Видалення інсталятора…",
"delete": "Видалити інсталятор",
"remove_from_list": "Видалити",
"delete_modal_title": "Ви впевнені?",
"delete_modal_description": "Це видалить усі інсталяційні файли з вашого комп'ютера",
"install": "Встановити"
},
"settings": {
"downloads_path": "Тека завантажень",
"change": "Змінити",
"notifications": "Повідомлення",
"enable_download_notifications": "Після завершення завантаження",
"enable_repack_list_notifications": "Коли додається новий репак",
"telemetry": "Телеметрія",
"telemetry_description": "Відправляти анонімну статистику використання",
"behavior": "Поведінка",
"quit_app_instead_hiding": "Закривати програму замість того, щоб згортати її в трей",
"launch_with_system": "Запускати програми із запуском комп'ютера"
},
"notifications": {
"download_complete": "Завантаження завершено",
"game_ready_to_install": "{{title}} готова до встановлення",
"repack_list_updated": "Список репаків оновлено",
"repack_count_one": "{{count}} репак додано",
"repack_count_other": "{{count}} репаків додано"
},
"system_tray": {
"open": "Відкрити Hydra",
"quit": "Вийти"
},
"game_card": {
"no_downloads": "Немає доступних завантажень"
},
"binary_not_found_modal": {
"title": "Програми не встановлені",
"description": "Виконувані файли Wine або Lutris не знайдено у вашій системі",
"instructions": "Дізнайтеся правильний спосіб встановити будь-який з них на ваш дистрибутив Linux, щоб гра могла нормально працювати"
},
"modal": {
"close": "Закрити"
}
}

View File

@ -33,15 +33,6 @@ export const months = [
"Dec",
];
export enum GameStatus {
Seeding = "seeding",
Downloading = "downloading",
Paused = "paused",
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
}
export const defaultDownloadsPath = app.getPath("downloads");
export const databasePath = path.join(
@ -50,7 +41,5 @@ export const databasePath = path.join(
"hydra.db"
);
export const imageCachePath = path.join(app.getPath("userData"), ".imagecache");
export const INSTALLATION_ID_LENGTH = 6;
export const ACTIVATION_KEY_MULTIPLIER = 7;

View File

@ -7,9 +7,11 @@ import {
OneToOne,
JoinColumn,
} from "typeorm";
import type { GameShop } from "@types";
import { Repack } from "./repack.entity";
import type { GameShop } from "@types";
import { Downloader, GameStatus } from "@shared";
@Entity("game")
export class Game {
@PrimaryGeneratedColumn()
@ -40,8 +42,14 @@ export class Game {
shop: GameShop;
@Column("text", { nullable: true })
status: string | null;
status: GameStatus | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;
/**
* Progress is a float between 0 and 1
*/
@Column("float", { default: 0 })
progress: number;

View File

@ -17,6 +17,9 @@ export class UserPreferences {
@Column("text", { default: "en" })
language: string;
@Column("text", { nullable: true })
realDebridApiToken: string | null;
@Column("boolean", { default: false })
downloadNotificationsEnabled: boolean;

View File

@ -8,42 +8,35 @@ import { requestSteam250 } from "@main/services";
const repacks = stateManager.getValue("repacks");
interface GetStringForLookup {
(index: number): string;
}
const getStringForLookup = (index: number): string => {
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
const resultSize = 12;
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const getStringForLookup = (index: number): string => {
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
if (!repacks.length) return [];
const resultSize = 12;
if (category === "trending") {
return getTrendingCatalogue(resultSize);
} else {
return getRecentlyAddedCatalogue(
resultSize,
resultSize,
getStringForLookup
);
}
return getRecentlyAddedCatalogue(resultSize);
};
const getTrendingCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
const trendingGames = await requestSteam250("/30day");
const trendingGames = await requestSteam250("/90day");
for (
let i = 0;
i < trendingGames.length && results.length < resultSize;
@ -51,7 +44,7 @@ const getTrendingCatalogue = async (
) {
if (!trendingGames[i]) continue;
const { title, objectID } = trendingGames[i];
const { title, objectID } = trendingGames[i]!;
const repacks = searchRepacks(title);
if (title && repacks.length) {
@ -69,11 +62,8 @@ const getTrendingCatalogue = async (
};
const getRecentlyAddedCatalogue = async (
resultSize: number,
requestSize: number,
getStringForLookup: GetStringForLookup
resultSize: number
): Promise<CatalogueEntry[]> => {
let lookupRequest = [];
const results: CatalogueEntry[] = [];
for (let i = 0; results.length < resultSize; i++) {
@ -84,15 +74,7 @@ const getRecentlyAddedCatalogue = async (
continue;
}
lookupRequest.push(searchGames({ query: stringForLookup }));
if (lookupRequest.length < requestSize) {
continue;
}
const games = (await Promise.all(lookupRequest)).map((value) =>
value.at(0)
);
const games = searchGames({ query: stringForLookup });
for (const game of games) {
const isAlreadyIncluded = results.some(
@ -105,7 +87,6 @@ const getRecentlyAddedCatalogue = async (
results.push(game);
}
lookupRequest = [];
}
return results.slice(0, resultSize);

View File

@ -28,8 +28,8 @@ export const generateYML = (game: Game) => {
{
task: {
executable: path.join(
game.downloadPath,
game.folderName,
game.downloadPath!,
game.folderName!,
"setup.exe"
),
name: "wineexec",

View File

@ -10,7 +10,9 @@ const closeGame = async (
gameId: number
) => {
const processes = await getProcesses();
const game = await gameRepository.findOne({ where: { id: gameId } });
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game) return false;

View File

@ -1,7 +1,7 @@
import path from "node:path";
import fs from "node:fs";
import { GameStatus } from "@main/constants";
import { GameStatus } from "@shared";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
@ -11,11 +11,12 @@ import { registerEvent } from "../register-event";
const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
): Promise<void> => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: GameStatus.Cancelled,
isDeleted: false,
},
});
@ -37,7 +38,8 @@ const deleteGameFolder = async (
logger.error(error);
reject();
}
resolve(null);
resolve();
}
);
});

View File

@ -1,8 +1,8 @@
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import { GameStatus } from "@shared";
import { sortBy } from "lodash-es";
const getLibrary = async () =>

View File

@ -13,13 +13,15 @@ const openGameInstaller = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.folderName) return true;
let gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName
game.folderName!
);
if (!fs.existsSync(gamePath)) {

View File

@ -1,6 +1,6 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { GameStatus } from "@main/constants";
import { GameStatus } from "@shared";
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,

View File

@ -7,8 +7,10 @@ const showOpenDialog = async (
options: Electron.OpenDialogOptions
) => {
if (WindowManager.mainWindow) {
dialog.showOpenDialog(WindowManager.mainWindow, options);
return dialog.showOpenDialog(WindowManager.mainWindow, options);
}
throw new Error("Main window is not available");
};
registerEvent(showOpenDialog, {

View File

@ -1,10 +1,11 @@
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { WindowManager, writePipe } from "@main/services";
import { WindowManager } from "@main/services";
import { In } from "typeorm";
import { DownloadManager } from "@main/services";
import { GameStatus } from "@shared";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -13,17 +14,20 @@ const cancelGameDownload = async (
const game = await gameRepository.findOne({
where: {
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(
@ -41,7 +45,6 @@ const cancelGameDownload = async (
game.status !== GameStatus.Paused &&
game.status !== GameStatus.Seeding
) {
writePipe.write({ action: "cancel" });
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
}
});

View File

@ -1,14 +1,15 @@
import { WindowManager, writePipe } from "@main/services";
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { In } from "typeorm";
import { DownloadManager, WindowManager } from "@main/services";
import { GameStatus } from "@shared";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
DownloadManager.pauseDownload();
await gameRepository
.update(
{
@ -22,10 +23,7 @@ const pauseGameDownload = async (
{ status: GameStatus.Paused }
)
.then((result) => {
if (result.affected) {
writePipe.write({ action: "pause" });
WindowManager.mainWindow?.setProgressBar(-1);
}
if (result.affected) WindowManager.mainWindow?.setProgressBar(-1);
});
};

View File

@ -1,9 +1,9 @@
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { In } from "typeorm";
import { writePipe } from "@main/services";
import { DownloadManager } from "@main/services";
import { GameStatus } from "@shared";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -12,23 +12,18 @@ const resumeGameDownload = async (
const game = await gameRepository.findOne({
where: {
id: gameId,
isDeleted: false,
},
relations: { repack: true },
});
if (!game) return;
writePipe.write({ action: "pause" });
DownloadManager.pauseDownload();
if (game.status === GameStatus.Paused) {
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
writePipe.write({
action: "start",
game_id: gameId,
magnet: game.repack.magnet,
save_path: downloadsPath,
});
DownloadManager.resumeDownload(gameId);
await gameRepository.update(
{
@ -44,7 +39,7 @@ const resumeGameDownload = async (
await gameRepository.update(
{ id: game.id },
{
status: GameStatus.DownloadingMetadata,
status: GameStatus.Downloading,
downloadPath: downloadsPath,
}
);

View File

@ -1,12 +1,17 @@
import { getSteamGameIconUrl, writePipe } from "@main/services";
import { gameRepository, repackRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { getSteamGameIconUrl } from "@main/services";
import {
gameRepository,
repackRepository,
userPreferencesRepository,
} from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { In } from "typeorm";
import { DownloadManager } from "@main/services";
import { Downloader, GameStatus } from "@shared";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -16,6 +21,14 @@ const startGameDownload = async (
gameShop: GameShop,
downloadPath: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const downloader = userPreferences?.realDebridApiToken
? Downloader.RealDebrid
: Downloader.Torrent;
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
@ -29,13 +42,8 @@ const startGameDownload = async (
}),
]);
if (!repack) return;
if (game?.status === GameStatus.Downloading) {
return;
}
writePipe.write({ action: "pause" });
if (!repack || game?.status === GameStatus.Downloading) return;
DownloadManager.pauseDownload();
await gameRepository.update(
{
@ -56,17 +64,13 @@ const startGameDownload = async (
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadPath,
downloader,
repack: { id: repackId },
isDeleted: false,
}
);
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadPath,
});
DownloadManager.downloadGame(game.id);
game.status = GameStatus.DownloadingMetadata;
@ -78,18 +82,14 @@ const startGameDownload = async (
title,
iconUrl,
objectID,
downloader,
shop: gameShop,
status: GameStatus.DownloadingMetadata,
downloadPath: downloadPath,
status: GameStatus.Downloading,
downloadPath,
repack: { id: repackId },
});
writePipe.write({
action: "start",
game_id: createdGame.id,
magnet: repack.magnet,
save_path: downloadPath,
});
DownloadManager.downloadGame(createdGame.id);
const { repack: _, ...rest } = createdGame;

View File

@ -2,11 +2,16 @@ import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
import { RealDebridClient } from "@main/services/real-debrid";
const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences>
) => {
if (preferences.realDebridApiToken) {
RealDebridClient.authorize(preferences.realDebridApiToken);
}
await userPreferencesRepository.upsert(
{
id: 1,

View File

@ -1,14 +1,13 @@
import { stateManager } from "./state-manager";
import { GameStatus, repackers } from "./constants";
import { repackers } from "./constants";
import {
getNewGOGGames,
getNewRepacksFromCPG,
getNewRepacksFromUser,
getNewRepacksFromXatab,
getNewRepacksFromOnlineFix,
readPipe,
startProcessWatcher,
writePipe,
DownloadManager,
} from "./services";
import {
gameRepository,
@ -17,42 +16,16 @@ import {
steamGameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/torrent-client";
import { Repack } from "./entity";
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 { RealDebridClient } from "./services/real-debrid";
startProcessWatcher();
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
relations: { repack: true },
});
if (game) {
writePipe.write({
action: "start",
game_id: game.id,
magnet: game.repack.magnet,
save_path: game.downloadPath,
});
}
readPipe.socket?.on("data", (data) => {
TorrentClient.onSocketData(data);
});
});
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackers) {
await getNewRepacksFromUser(
@ -62,11 +35,7 @@ const track1337xUsers = async (existingRepacks: Repack[]) => {
}
};
const checkForNewRepacks = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const checkForNewRepacks = async (userPreferences: UserPreferences | null) => {
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
@ -104,7 +73,7 @@ const checkForNewRepacks = async () => {
});
};
const loadState = async () => {
const loadState = async (userPreferences: UserPreferences | null) => {
const [friendlyNames, repacks, steamGames] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
@ -124,6 +93,33 @@ const loadState = async () => {
stateManager.setValue("steamGames", steamGames);
import("./events");
if (userPreferences?.realDebridApiToken)
await RealDebridClient.authorize(userPreferences?.realDebridApiToken);
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
isDeleted: false,
},
relations: { repack: true },
});
await TorrentDownloader.startClient();
if (game) {
DownloadManager.resumeDownload(game.id);
}
};
loadState().then(() => checkForNewRepacks());
userPreferencesRepository
.findOne({
where: { id: 1 },
})
.then((userPreferences) => {
loadState(userPreferences).then(() => checkForNewRepacks(userPreferences));
});

View File

@ -0,0 +1,76 @@
import { gameRepository } from "@main/repository";
import type { Game } from "@main/entity";
import { Downloader } from "@shared";
import { writePipe } from "./fifo";
import { RealDebridDownloader } from "./downloaders";
export class DownloadManager {
private static gameDownloading: Game;
static async getGame(gameId: number) {
return gameRepository.findOne({
where: { id: gameId, isDeleted: false },
relations: {
repack: true,
},
});
}
static async cancelDownload() {
if (
this.gameDownloading &&
this.gameDownloading.downloader === Downloader.Torrent
) {
writePipe.write({ action: "cancel" });
} else {
RealDebridDownloader.destroy();
}
}
static async pauseDownload() {
if (
this.gameDownloading &&
this.gameDownloading.downloader === Downloader.Torrent
) {
writePipe.write({ action: "pause" });
} else {
RealDebridDownloader.destroy();
}
}
static async resumeDownload(gameId: number) {
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,
});
} else {
RealDebridDownloader.startDownload(game!);
}
this.gameDownloading = game!;
}
static async downloadGame(gameId: number) {
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,
});
} else {
RealDebridDownloader.startDownload(game!);
}
this.gameDownloading = game!;
}
}

View File

@ -0,0 +1,85 @@
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,
})
)
);
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./real-debrid.downloader";
export * from "./torrent.downloader";

View File

@ -0,0 +1,115 @@
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
// );
});
}
}

View File

@ -0,0 +1,160 @@
import path from "node:path";
import cp from "node:child_process";
import fs from "node:fs";
import * as Sentry from "@sentry/electron/main";
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,
});
} catch (err) {
Sentry.captureException(err);
} 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,
});
return;
}
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;
}
}

View File

@ -6,6 +6,7 @@ export * from "./steam-grid";
export * from "./update-resolver";
export * from "./window-manager";
export * from "./fifo";
export * from "./torrent-client";
export * from "./downloaders";
export * from "./download-manager";
export * from "./how-long-to-beat";
export * from "./process-watcher";

View File

@ -16,6 +16,7 @@ export const startProcessWatcher = async () => {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
isDeleted: false,
},
});

View File

@ -0,0 +1,102 @@
import { Game } from "@main/entity";
import type {
RealDebridAddMagnet,
RealDebridTorrentInfo,
RealDebridUnrestrictLink,
} from "./real-debrid.types";
import axios, { AxiosInstance } from "axios";
const base = "https://api.real-debrid.com/rest/1.0";
export class RealDebridClient {
private static instance: AxiosInstance;
static async addMagnet(magnet: string) {
const searchParams = new URLSearchParams();
searchParams.append("magnet", magnet);
const response = await this.instance.post<RealDebridAddMagnet>(
"/torrents/addMagnet",
searchParams.toString()
);
return response.data;
}
static async getInfo(id: string) {
const response = await this.instance.get<RealDebridTorrentInfo>(
`/torrents/info/${id}`
);
return response.data;
}
static async selectAllFiles(id: string) {
const searchParams = new URLSearchParams();
searchParams.append("files", "all");
await this.instance.post(
`/torrents/selectFiles/${id}`,
searchParams.toString()
);
}
static async unrestrictLink(link: string) {
const searchParams = new URLSearchParams();
searchParams.append("link", link);
const response = await this.instance.post<RealDebridUnrestrictLink>(
"/unrestrict/link",
searchParams.toString()
);
return response.data;
}
static async getAllTorrentsFromUser() {
const response =
await this.instance.get<RealDebridTorrentInfo[]>("/torrents");
return response.data;
}
static extractSHA1FromMagnet(magnet: string) {
return magnet.match(/btih:([0-9a-fA-F]*)/)?.[1].toLowerCase();
}
static async getDownloadUrl(game: Game) {
const torrents = await RealDebridClient.getAllTorrentsFromUser();
const hash = RealDebridClient.extractSHA1FromMagnet(game!.repack.magnet);
let torrent = torrents.find((t) => t.hash === hash);
if (!torrent) {
const magnet = await RealDebridClient.addMagnet(game!.repack.magnet);
if (magnet && magnet.id) {
await RealDebridClient.selectAllFiles(magnet.id);
torrent = await RealDebridClient.getInfo(magnet.id);
}
}
if (torrent) {
const { links } = torrent;
const { download } = await RealDebridClient.unrestrictLink(links[0]);
if (!download) {
throw new Error("Torrent not cached on Real Debrid");
}
return download;
}
throw new Error();
}
static async authorize(apiToken: string) {
this.instance = axios.create({
baseURL: base,
headers: {
Authorization: `Bearer ${apiToken}`,
},
});
}
}

View File

@ -0,0 +1,51 @@
export interface RealDebridUnrestrictLink {
id: string;
filename: string;
mimeType: string;
filesize: number;
link: string;
host: string;
host_icon: string;
chunks: number;
crc: number;
download: string;
streamable: number;
}
export interface RealDebridAddMagnet {
id: string;
// URL of the created ressource
uri: string;
}
export interface RealDebridTorrentInfo {
id: string;
filename: string;
original_filename: string; // Original name of the torrent
hash: string; // SHA1 Hash of the torrent
bytes: number; // Size of selected files only
original_bytes: number; // Total size of the torrent
host: string; // Host main domain
split: number; // Split size of links
progress: number; // Possible values: 0 to 100
status: string; // Current status of the torrent: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead
added: string; // jsonDate
files: [
{
id: number;
path: string; // Path to the file inside the torrent, starting with "/"
bytes: number;
selected: number; // 0 or 1
},
{
id: number;
path: string; // Path to the file inside the torrent, starting with "/"
bytes: number;
selected: number; // 0 or 1
},
];
links: string[];
ended: string; // !! Only present when finished, jsonDate
speed: number; // !! Only present in "downloading", "compressing", "uploading" status
seeders: number; // !! Only present in "downloading", "magnet_conversion" status
}

View File

@ -33,9 +33,9 @@ const getTorrentDetails = async (path: string) => {
return {
magnet: $a?.href,
fileSize: $totalSize.querySelector("span").textContent ?? undefined,
fileSize: $totalSize.querySelector("span")!.textContent,
uploadDate: formatUploadDate(
$dateUploaded.querySelector("span").textContent!
$dateUploaded.querySelector("span")!.textContent!
),
};
};
@ -65,8 +65,7 @@ export const getTorrentListLastPage = async (user: string) => {
export const extractTorrentsFromDocument = async (
page: number,
user: string,
document: Document,
existingRepacks: Repack[] = []
document: Document
) => {
const $trs = Array.from(document.querySelectorAll("tbody tr"));
@ -78,24 +77,13 @@ export const extractTorrentsFromDocument = async (
const url = $name.href;
const title = $name.textContent ?? "";
if (existingRepacks.some((repack) => repack.title === title)) {
return {
title,
magnet: "",
fileSize: null,
uploadDate: null,
repacker: user,
page,
};
}
const details = await getTorrentDetails(url);
return {
title,
magnet: details.magnet,
fileSize: details.fileSize ?? null,
uploadDate: details.uploadDate ?? null,
fileSize: details.fileSize ?? "N/A",
uploadDate: details.uploadDate ?? new Date(),
repacker: user,
page,
};
@ -114,13 +102,11 @@ export const getNewRepacksFromUser = async (
const repacks = await extractTorrentsFromDocument(
page,
user,
window.document,
existingRepacks
window.document
);
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)

View File

@ -4,6 +4,7 @@ import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import { logger } from "../logger";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
export const getNewRepacksFromCPG = async (
existingRepacks: Repack[] = [],
@ -13,11 +14,11 @@ export const getNewRepacksFromCPG = async (
const { window } = new JSDOM(data);
const repacks = [];
const repacks: QueryDeepPartialEntity<Repack>[] = [];
try {
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
const $title = $post.querySelector(".entry-title");
const $title = $post.querySelector(".entry-title")!;
const uploadDate = $post.querySelector("time")?.getAttribute("datetime");
const $downloadInfo = Array.from(
@ -31,26 +32,25 @@ export const getNewRepacksFromCPG = async (
$a.textContent?.startsWith("Magent")
);
const fileSize = $downloadInfo.textContent
const fileSize = ($downloadInfo?.textContent ?? "")
.split("Download link => ")
.at(1);
repacks.push({
title: $title.textContent,
title: $title.textContent!,
fileSize: fileSize ?? "N/A",
magnet: $magnet.href,
magnet: $magnet!.href,
repacker: "CPG",
page,
uploadDate: new Date(uploadDate),
uploadDate: uploadDate ? new Date(uploadDate) : new Date(),
});
});
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromCPG" });
} catch (err: unknown) {
logger.error((err as Error).message, { method: "getNewRepacksFromCPG" });
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)

View File

@ -16,14 +16,14 @@ const getGOGGame = async (url: string) => {
const $em = window.document.querySelector(
"p:not(.lightweight-accordion *) em"
);
const fileSize = $em.textContent.split("Size: ").at(1);
)!;
const fileSize = $em.textContent!.split("Size: ").at(1);
const $downloadButton = window.document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
const { searchParams } = new URL($downloadButton.href);
const magnet = Buffer.from(searchParams.get("url"), "base64").toString(
const magnet = Buffer.from(searchParams.get("url")!, "base64").toString(
"utf-8"
);
@ -50,10 +50,10 @@ export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
const $lis = Array.from($ul.querySelectorAll("li"));
for (const $li of $lis) {
const $a = $li.querySelector("a");
const $a = $li.querySelector("a")!;
const href = $a.href;
const title = $a.textContent.trim();
const title = $a.textContent!.trim();
const gameExists = existingRepacks.some(
(existingRepack) => existingRepack.title === title

View File

@ -13,6 +13,9 @@ import { ru } from "date-fns/locale";
import { onlinefixFormatter } from "@main/helpers";
import makeFetchCookie from "fetch-cookie";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
const ONLINE_FIX_URL = "https://online-fix.me/";
export const getNewRepacksFromOnlineFix = async (
existingRepacks: Repack[] = [],
@ -27,14 +30,14 @@ export const getNewRepacksFromOnlineFix = async (
const http = makeFetchCookie(fetch, cookieJar);
if (page === 1) {
await http("https://online-fix.me/");
await http(ONLINE_FIX_URL);
const preLogin =
((await http("https://online-fix.me/engine/ajax/authtoken.php", {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
Referer: "https://online-fix.me/",
Referer: ONLINE_FIX_URL,
},
}).then((res) => res.json())) as {
field: string;
@ -50,11 +53,11 @@ export const getNewRepacksFromOnlineFix = async (
[preLogin.field]: preLogin.value,
});
await http("https://online-fix.me/", {
await http(ONLINE_FIX_URL, {
method: "POST",
headers: {
Referer: "https://online-fix.me",
Origin: "https://online-fix.me",
Referer: ONLINE_FIX_URL,
Origin: ONLINE_FIX_URL,
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
@ -149,13 +152,8 @@ export const getNewRepacksFromOnlineFix = async (
const torrentSizeInBytes = torrent.length;
if (!torrentSizeInBytes) return;
const fileSizeFormatted =
torrentSizeInBytes >= 1024 ** 3
? `${(torrentSizeInBytes / 1024 ** 3).toFixed(1)}GBs`
: `${(torrentSizeInBytes / 1024 ** 2).toFixed(1)}MBs`;
repacks.push({
fileSize: fileSizeFormatted,
fileSize: formatBytes(torrentSizeInBytes),
magnet: magnetLink,
page: 1,
repacker: "onlinefix",

View File

@ -7,6 +7,8 @@ import { requestWebPage, savePage } from "./helpers";
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
import type { Instance } from "parse-torrent";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { formatBytes } from "@shared";
const worker = createWorker({});
@ -23,10 +25,9 @@ const formatXatabDate = (str: string) => {
return date;
};
const formatXatabDownloadSize = (str: string) =>
str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
const getXatabRepack = (url: string) => {
const getXatabRepack = (
url: string
): Promise<{ fileSize: string; magnet: string; uploadDate: Date }> => {
return new Promise((resolve) => {
(async () => {
const data = await requestWebPage(url);
@ -34,7 +35,6 @@ const getXatabRepack = (url: string) => {
const { document } = window;
const $uploadDate = document.querySelector(".entry__date");
const $size = document.querySelector(".entry__info-size");
const $downloadButton = document.querySelector(
".download-torrent"
@ -42,17 +42,13 @@ const getXatabRepack = (url: string) => {
if (!$downloadButton) throw new Error("Download button not found");
const onMessage = (torrent: Instance) => {
worker.once("message", (torrent: Instance) => {
resolve({
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
fileSize: formatBytes(torrent.length ?? 0),
magnet: toMagnetURI(torrent),
uploadDate: formatXatabDate($uploadDate.textContent),
uploadDate: formatXatabDate($uploadDate!.textContent!),
});
worker.removeListener("message", onMessage);
};
worker.once("message", onMessage);
});
})();
});
};
@ -65,7 +61,7 @@ export const getNewRepacksFromXatab = async (
const { window } = new JSDOM(data);
const repacks = [];
const repacks: QueryDeepPartialEntity<Repack>[] = [];
for (const $a of Array.from(
window.document.querySelectorAll(".entry__title a")
@ -74,7 +70,7 @@ export const getNewRepacksFromXatab = async (
const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
repacks.push({
title: $a.textContent,
title: $a.textContent!,
repacker: "Xatab",
...repack,
page,

View File

@ -1,3 +1,4 @@
import axios from "axios";
import { getSteamAppAsset } from "@main/helpers";
export interface SteamGridResponse {
@ -27,33 +28,35 @@ export const getSteamGridData = async (
): Promise<SteamGridResponse> => {
const searchParams = new URLSearchParams(params);
const response = await fetch(
if (!import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY) {
throw new Error("STEAMGRIDDB_API_KEY is not set");
}
const response = await axios.get(
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
},
}
);
return response.json();
return response.data;
};
export const getSteamGridGameById = async (
id: number
): Promise<SteamGridGameResponse> => {
const response = await fetch(
const response = await axios.get(
`https://www.steamgriddb.com/api/public/game/${id}`,
{
method: "GET",
headers: {
Referer: "https://www.steamgriddb.com/",
},
}
);
return response.json();
return response.data;
};
export const getSteamGameIconUrl = async (objectID: string) => {

View File

@ -1,169 +0,0 @@
import path from "node:path";
import cp from "node:child_process";
import fs from "node:fs";
import * as Sentry from "@sentry/electron/main";
import { Notification, app, dialog } from "electron";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { t } from "i18next";
import { WindowManager } from "./window-manager";
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 TorrentClient {
public static startTorrentClient(
writePipePath: string,
readPipePath: string
) {
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
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,
});
return;
}
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit",
});
}
private static getTorrentStateName(state: TorrentState) {
if (state === TorrentState.CheckingFiles) return "checking_files";
if (state === TorrentState.Downloading) return "downloading";
if (state === TorrentState.DownloadingMetadata)
return "downloading_metadata";
if (state === TorrentState.Finished) return "finished";
if (state === TorrentState.Seeding) return "seeding";
return "";
}
private static getGameProgress(game: Game) {
if (game.status === "checking_files") return game.fileVerificationProgress;
return game.progress;
}
public static async onSocketData(data: Buffer) {
const message = Buffer.from(data).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;
}
await gameRepository.update({ id: payload.gameId }, updatePayload);
const game = await gameRepository.findOne({
where: { id: payload.gameId },
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({ ...payload, game }))
);
}
} catch (err) {
Sentry.captureException(err);
}
}
}

View File

@ -105,7 +105,7 @@ export class WindowManager {
tray.setToolTip("Hydra");
tray.setContextMenu(contextMenu);
if (process.platform === "win32") {
if (process.platform === "win32" || process.platform === "linux") {
tray.addListener("click", () => {
if (this.mainWindow) {
if (WindowManager.mainWindow?.isMinimized())

View File

@ -20,6 +20,7 @@ import {
setRepackersFriendlyNames,
toggleDraggingDisabled,
} from "@renderer/features";
import { GameStatusHelper } from "@shared";
document.body.classList.add(themeClass);
@ -31,7 +32,7 @@ export function App({ children }: AppProps) {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
const { clearDownload, addPacket } = useDownload();
const { clearDownload, setLastPacket } = useDownload();
const dispatch = useAppDispatch();
@ -57,20 +58,20 @@ export function App({ children }: AppProps) {
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (downloadProgress.game.progress === 1) {
if (GameStatusHelper.isReady(downloadProgress.game.status)) {
clearDownload();
updateLibrary();
return;
}
addPacket(downloadProgress);
setLastPacket(downloadProgress);
}
);
return () => {
unsubscribe();
};
}, [clearDownload, addPacket, updateLibrary]);
}, [clearDownload, setLastPacket, updateLibrary]);
const handleSearch = useCallback(
(query: string) => {

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="#8e919b" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M41.625,10.76953c-3.98047,-3.20313 -10.27734,-3.74609 -10.54687,-3.76563c-0.41797,-0.03516 -0.81641,0.19922 -0.98828,0.58594c-0.01562,0.02344 -0.15234,0.33984 -0.30469,0.83203c2.63281,0.44531 5.86719,1.33984 8.79297,3.15625c0.46875,0.28906 0.61328,0.90625 0.32422,1.375c-0.19141,0.30859 -0.51562,0.47656 -0.85156,0.47656c-0.17969,0 -0.36328,-0.05078 -0.52734,-0.15234c-5.03125,-3.12109 -11.3125,-3.27734 -12.52344,-3.27734c-1.21094,0 -7.49609,0.15625 -12.52344,3.27734c-0.46875,0.29297 -1.08594,0.14844 -1.375,-0.32031c-0.29297,-0.47266 -0.14844,-1.08594 0.32031,-1.37891c2.92578,-1.8125 6.16016,-2.71094 8.79297,-3.15234c-0.15234,-0.49609 -0.28906,-0.80859 -0.30078,-0.83594c-0.17578,-0.38672 -0.57031,-0.62891 -0.99219,-0.58594c-0.26953,0.01953 -6.56641,0.5625 -10.60156,3.80859c-2.10547,1.94922 -6.32031,13.33984 -6.32031,23.1875c0,0.17578 0.04688,0.34375 0.13281,0.49609c2.90625,5.10938 10.83984,6.44531 12.64844,6.50391c0.00781,0 0.01953,0 0.03125,0c0.32031,0 0.62109,-0.15234 0.80859,-0.41016l1.82813,-2.51562c-4.93359,-1.27344 -7.45312,-3.4375 -7.59766,-3.56641c-0.41406,-0.36328 -0.45312,-0.99609 -0.08594,-1.41016c0.36328,-0.41406 0.99609,-0.45312 1.41016,-0.08984c0.05859,0.05469 4.69922,3.99219 13.82422,3.99219c9.14063,0 13.78125,-3.95312 13.82813,-3.99219c0.41406,-0.35937 1.04297,-0.32422 1.41016,0.09375c0.36328,0.41406 0.32422,1.04297 -0.08984,1.40625c-0.14453,0.12891 -2.66406,2.29297 -7.59766,3.56641l1.82813,2.51563c0.1875,0.25781 0.48828,0.41016 0.80859,0.41016c0.01172,0 0.02344,0 0.03125,0c1.80859,-0.05859 9.74219,-1.39453 12.64844,-6.50391c0.08594,-0.15234 0.13281,-0.32031 0.13281,-0.49609c0,-9.84766 -4.21484,-21.23828 -6.375,-23.23047zM18.5,30c-1.93359,0 -3.5,-1.78906 -3.5,-4c0,-2.21094 1.56641,-4 3.5,-4c1.93359,0 3.5,1.78906 3.5,4c0,2.21094 -1.56641,4 -3.5,4zM31.5,30c-1.93359,0 -3.5,-1.78906 -3.5,-4c0,-2.21094 1.56641,-4 3.5,-4c1.93359,0 3.5,1.78906 3.5,4c0,2.21094 -1.56641,4 -3.5,4z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M41.4193 7.30899C41.4193 7.30899 45.3046 5.79399 44.9808 9.47328C44.8729 10.9883 43.9016 16.2908 43.1461 22.0262L40.5559 39.0159C40.5559 39.0159 40.3401 41.5048 38.3974 41.9377C36.4547 42.3705 33.5408 40.4227 33.0011 39.9898C32.5694 39.6652 24.9068 34.7955 22.2086 32.4148C21.4531 31.7655 20.5897 30.4669 22.3165 28.9519L33.6487 18.1305C34.9438 16.8319 36.2389 13.8019 30.8426 17.4812L15.7331 27.7616C15.7331 27.7616 14.0063 28.8437 10.7686 27.8698L3.75342 25.7055C3.75342 25.7055 1.16321 24.0823 5.58815 22.459C16.3807 17.3729 29.6555 12.1786 41.4193 7.30899Z" fill="currentColor"></path> </g></svg>

After

Width:  |  Height:  |  Size: 838 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="#8e919b" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M5.91992,6l14.66211,21.375l-14.35156,16.625h3.17969l12.57617,-14.57812l10,14.57813h12.01367l-15.31836,-22.33008l13.51758,-15.66992h-3.16992l-11.75391,13.61719l-9.3418,-13.61719zM9.7168,8h7.16406l23.32227,34h-7.16406z"></path></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="16px" height="16px"><g fill="currentColor" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M5.91992,6l14.66211,21.375l-14.35156,16.625h3.17969l12.57617,-14.57812l10,14.57813h12.01367l-15.31836,-22.33008l13.51758,-15.66992h-3.16992l-11.75391,13.61719l-9.3418,-13.61719zM9.7168,8h7.16406l23.32227,34h-7.16406z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 697 B

After

Width:  |  Height:  |  Size: 702 B

View File

@ -7,13 +7,17 @@ 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, isDownloading } = useDownload();
const { game, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading =
game && GameStatusHelper.isDownloading(game.status ?? null);
const [version, setVersion] = useState("");
@ -22,11 +26,11 @@ export function BottomPanel() {
}, []);
const status = useMemo(() => {
if (isDownloading && game) {
if (game.status === "downloading_metadata")
if (isGameDownloading) {
if (game.status === GameStatus.DownloadingMetadata)
return t("downloading_metadata", { title: game.title });
if (game.status === "checking_files")
if (game.status === GameStatus.CheckingFiles)
return t("checking_files", {
title: game.title,
percentage: progress,
@ -41,13 +45,13 @@ export function BottomPanel() {
}
return t("no_downloads_in_progress");
}, [t, game, progress, eta, isDownloading, downloadSpeed]);
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
return (
<footer
className={styles.bottomPanel}
style={{
background: isDownloading
background: isGameDownloading
? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
: vars.color.darkBackground,
}}
@ -60,7 +64,7 @@ export function BottomPanel() {
<small>{status}</small>
</button>
<small>
<small tabIndex={0}>
v{version} &quot;{VERSION_CODENAME}&quot;
</small>
</footer>

View File

@ -19,6 +19,7 @@ const base = style({
":disabled": {
opacity: vars.opacity.disabled,
pointerEvents: "none",
cursor: "not-allowed",
},
});

View File

@ -17,9 +17,9 @@ export function Button({
}: ButtonProps) {
return (
<button
{...props}
type="button"
className={cn(styles.button[theme], className)}
{...props}
>
{children}
</button>

View File

@ -24,7 +24,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
/>
{props.checked && <CheckIcon />}
</div>
<label htmlFor={id} className={styles.checkboxLabel}>
<label htmlFor={id} className={styles.checkboxLabel} tabIndex={0}>
{label}
</label>
</div>

View File

@ -7,3 +7,4 @@ export * from "./modal/modal";
export * from "./sidebar/sidebar";
export * from "./text-field/text-field";
export * from "./checkbox-field/checkbox-field";
export * from "./link/link";

View File

@ -0,0 +1,9 @@
import { style } from "@vanilla-extract/css";
export const link = style({
textDecoration: "none",
color: "#C0C1C7",
":hover": {
textDecoration: "underline",
},
});

View File

@ -0,0 +1,33 @@
import { Link as ReactRouterDomLink, LinkProps } from "react-router-dom";
import cn from "classnames";
import * as styles from "./link.css";
export function Link({ children, to, className, ...props }: LinkProps) {
const openExternal = (event: React.MouseEvent) => {
event.preventDefault();
window.electron.openExternal(to as string);
};
if (typeof to === "string" && to.startsWith("http")) {
return (
<a
href={to}
className={cn(styles.link, className)}
onClick={openExternal}
{...props}
>
{children}
</a>
);
}
return (
<ReactRouterDomLink
className={cn(styles.link, className)}
to={to}
{...props}
>
{children}
</ReactRouterDomLink>
);
}

View File

@ -10,10 +10,11 @@ import { useDownload, useLibrary } from "@renderer/hooks";
import { routes } from "./routes";
import { MarkGithubIcon } from "@primer/octicons-react";
import DiscordLogo from "@renderer/assets/discord-icon.svg?react";
import TelegramLogo from "@renderer/assets/telegram-icon.svg?react";
import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css";
import { GameStatus, GameStatusHelper } from "@shared";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -35,9 +36,9 @@ export function Sidebar() {
const socials = [
{
url: "https://discord.gg/hydralauncher",
icon: <DiscordLogo />,
label: t("discord"),
url: "https://t.me/hydralauncher",
icon: <TelegramLogo />,
label: t("telegram"),
},
{
url: "https://twitter.com/hydralauncher",
@ -60,9 +61,7 @@ export function Sidebar() {
}, [gameDownloading?.id, updateLibrary]);
const isDownloading = library.some((game) =>
["downloading", "checking_files", "downloading_metadata"].includes(
game.status
)
GameStatusHelper.isDownloading(game.status)
);
const sidebarRef = useRef<HTMLElement>(null);
@ -121,15 +120,14 @@ export function Sidebar() {
}, [isResizing]);
const getGameTitle = (game: Game) => {
if (game.status === "paused") return t("paused", { title: game.title });
if (game.status === GameStatus.Paused)
return t("paused", { title: game.title });
if (gameDownloading?.id === game.id) {
const isVerifying = ["downloading_metadata", "checking_files"].includes(
gameDownloading?.status
);
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
if (isVerifying)
return t(gameDownloading.status, {
return t(gameDownloading.status!, {
title: game.title,
percentage: progress,
});
@ -204,7 +202,7 @@ export function Sidebar() {
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === "cancelled",
muted: game.status === GameStatus.Cancelled,
})}
>
<button

View File

@ -2,6 +2,13 @@ import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const textFieldContainer = style({
flex: "1",
gap: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
});
export const textField = recipe({
base: {
display: "inline-flex",
@ -50,9 +57,3 @@ export const textFieldInput = style({
cursor: "text",
},
});
export const label = style({
marginBottom: `${SPACING_UNIT}px`,
display: "block",
color: vars.color.bodyText,
});

View File

@ -8,29 +8,32 @@ export interface TextFieldProps
HTMLInputElement
> {
theme?: NonNullable<RecipeVariants<typeof styles.textField>>["theme"];
label?: string;
label?: string | React.ReactNode;
hint?: string | React.ReactNode;
textFieldProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
containerProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
}
export function TextField({
theme = "primary",
label,
hint,
textFieldProps,
containerProps,
...props
}: TextFieldProps) {
const [isFocused, setIsFocused] = useState(false);
const id = useId();
return (
<div style={{ flex: 1 }}>
{label && (
<label htmlFor={id} className={styles.label}>
{label}
</label>
)}
<div className={styles.textFieldContainer} {...containerProps}>
{label && <label tabIndex={0}>{label}</label>}
<div
className={styles.textField({ focused: isFocused, theme })}
@ -45,6 +48,8 @@ export function TextField({
{...props}
/>
</div>
{hint && <small tabIndex={0}>{hint}</small>}
</div>
);
}

View File

@ -3,13 +3,13 @@ import type { PayloadAction } from "@reduxjs/toolkit";
import type { TorrentProgress } from "@types";
interface DownloadState {
packets: TorrentProgress[];
lastPacket: TorrentProgress | null;
gameId: number | null;
gamesWithDeletionInProgress: number[];
}
const initialState: DownloadState = {
packets: [],
lastPacket: null,
gameId: null,
gamesWithDeletionInProgress: [],
};
@ -18,12 +18,12 @@ export const downloadSlice = createSlice({
name: "download",
initialState,
reducers: {
addPacket: (state, action: PayloadAction<TorrentProgress>) => {
state.packets = [...state.packets, action.payload];
setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
state.lastPacket = action.payload;
if (!state.gameId) state.gameId = action.payload.game.id;
},
clearDownload: (state) => {
state.packets = [];
state.lastPacket = null;
state.gameId = null;
},
setGameDeleting: (state, action: PayloadAction<number>) => {
@ -42,7 +42,7 @@ export const downloadSlice = createSlice({
});
export const {
addPacket,
setLastPacket,
clearDownload,
setGameDeleting,
removeGameFromDeleting,

View File

@ -21,7 +21,7 @@ export const getSteamLanguage = (language: string) => {
if (language.startsWith("pt")) return "brazilian";
if (language.startsWith("es")) return "spanish";
if (language.startsWith("fr")) return "french";
if (language.startsWith("ru")) return "russian";
if (language.startsWith("ru") || language.startsWith("be")) return "russian";
if (language.startsWith("it")) return "italian";
if (language.startsWith("hu")) return "hungarian";
if (language.startsWith("pl")) return "polish";

View File

@ -1,3 +1,4 @@
export * from "./use-download";
export * from "./use-library";
export * from "./use-date";
export * from "./redux";

View File

@ -1,15 +1,23 @@
import { formatDistance } from "date-fns";
import type { FormatDistanceOptions } from "date-fns";
import { ptBR, enUS, es, fr } from "date-fns/locale";
import { ptBR, enUS, es, fr, pl, hu, tr, ru, it, be } from "date-fns/locale";
import { useTranslation } from "react-i18next";
export function useDate() {
const { i18n } = useTranslation();
const { language } = i18n;
const getDateLocale = () => {
if (i18n.language.startsWith("pt")) return ptBR;
if (i18n.language.startsWith("es")) return es;
if (i18n.language.startsWith("fr")) return fr;
if (language.startsWith("pt")) return ptBR;
if (language.startsWith("es")) return es;
if (language.startsWith("fr")) return fr;
if (language.startsWith("hu")) return hu;
if (language.startsWith("pl")) return pl;
if (language.startsWith("tr")) return tr;
if (language.startsWith("ru")) return ru;
if (language.startsWith("it")) return it;
if (language.startsWith("be")) return be;
return enUS;
};

View File

@ -4,26 +4,24 @@ import { formatDownloadProgress } from "@renderer/helpers";
import { useLibrary } from "./use-library";
import { useAppDispatch, useAppSelector } from "./redux";
import {
addPacket,
setLastPacket,
clearDownload,
setGameDeleting,
removeGameFromDeleting,
} from "@renderer/features";
import type { GameShop, TorrentProgress } from "@types";
import { useDate } from "./use-date";
import { formatBytes } from "@renderer/utils";
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
export function useDownload() {
const { updateLibrary } = useLibrary();
const { formatDistance } = useDate();
const { packets, gamesWithDeletionInProgress } = useAppSelector(
const { lastPacket, gamesWithDeletionInProgress } = useAppSelector(
(state) => state.download
);
const dispatch = useAppDispatch();
const lastPacket = packets.at(-1);
const startDownload = (
repackId: number,
objectID: string,
@ -63,8 +61,8 @@ export function useDownload() {
updateLibrary();
});
const isVerifying = ["downloading_metadata", "checking_files"].includes(
lastPacket?.game.status ?? ""
const isVerifying = GameStatusHelper.isVerifying(
lastPacket?.game.status ?? null
);
const getETA = () => {
@ -84,7 +82,7 @@ export function useDownload() {
};
const getProgress = () => {
if (lastPacket?.game.status === "checking_files") {
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
}
@ -115,7 +113,6 @@ export function useDownload() {
isVerifying,
gameId: lastPacket?.game.id,
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
isDownloading: Boolean(lastPacket),
progress: getProgress(),
numPeers: lastPacket?.numPeers,
numSeeds: lastPacket?.numSeeds,
@ -128,6 +125,6 @@ export function useDownload() {
deleteGame,
isGameDeleting,
clearDownload: () => dispatch(clearDownload()),
addPacket: (packet: TorrentProgress) => dispatch(addPacket(packet)),
setLastPacket: (packet: TorrentProgress) => dispatch(setLastPacket(packet)),
};
}

View File

@ -2,12 +2,18 @@ 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.bodyText,
textAlign: "left",
marginBottom: `${SPACING_UNIT}px`,
fontSize: "16px",
display: "block",
":hover": {
@ -15,6 +21,17 @@ export const downloadTitle = style({
},
});
export const downloaderName = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
alignSelf: "flex-start",
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,

View File

@ -10,7 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css";
import { DeleteModal } from "./delete-modal";
import { formatBytes } from "@renderer/utils";
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
export function Downloads() {
const { library, updateLibrary } = useLibrary();
@ -28,7 +28,6 @@ export function Downloads() {
const {
game: gameDownloading,
progress,
isDownloading,
numPeers,
numSeeds,
pauseDownload,
@ -54,7 +53,7 @@ export function Downloads() {
});
const getFinalDownloadSize = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
if (!game) return "N/A";
if (game.fileSize) return formatBytes(game.fileSize);
@ -65,8 +64,13 @@ export function Downloads() {
return game.repack?.fileSize ?? "N/A";
};
const downloaderName = {
[Downloader.RealDebrid]: t("real_debrid"),
[Downloader.Torrent]: t("torrent"),
};
const getGameInfo = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game?.id)) {
@ -78,7 +82,8 @@ export function Downloads() {
<>
<p>{progress}</p>
{gameDownloading?.status !== "downloading" ? (
{gameDownloading?.status &&
gameDownloading?.status !== GameStatus.Downloading ? (
<p>{t(gameDownloading?.status)}</p>
) : (
<>
@ -86,16 +91,18 @@ export function Downloads() {
{formatBytes(gameDownloading?.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
<p>
{numPeers} peers / {numSeeds} seeds
</p>
{game.downloader === Downloader.Torrent && (
<p>
{numPeers} peers / {numSeeds} seeds
</p>
)}
</>
)}
</>
);
}
if (game?.status === "seeding") {
if (GameStatusHelper.isReady(game?.status)) {
return (
<>
<p>{game?.repack.title}</p>
@ -103,12 +110,11 @@ export function Downloads() {
</>
);
}
if (game?.status === "cancelled") return <p>{t("cancelled")}</p>;
if (game?.status === "downloading_metadata")
if (game?.status === GameStatus.Cancelled) return <p>{t("cancelled")}</p>;
if (game?.status === GameStatus.DownloadingMetadata)
return <p>{t("starting_download")}</p>;
if (game?.status === "paused") {
if (game?.status === GameStatus.Paused) {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
@ -126,7 +132,7 @@ export function Downloads() {
};
const getGameActions = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
const deleting = isGameDeleting(game.id);
@ -143,7 +149,7 @@ export function Downloads() {
);
}
if (game?.status === "paused") {
if (game?.status === GameStatus.Paused) {
return (
<>
<Button onClick={() => resumeDownload(game.id)} theme="outline">
@ -156,7 +162,7 @@ export function Downloads() {
);
}
if (game?.status === "seeding") {
if (GameStatusHelper.isReady(game?.status)) {
return (
<>
<Button
@ -174,7 +180,7 @@ export function Downloads() {
);
}
if (game?.status === "downloading_metadata") {
if (game?.status === GameStatus.DownloadingMetadata) {
return (
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
@ -239,7 +245,7 @@ export function Downloads() {
<li
key={game.id}
className={styles.download({
cancelled: game.status === "cancelled",
cancelled: game.status === GameStatus.Cancelled,
})}
>
<img
@ -249,16 +255,21 @@ export function Downloads() {
/>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<button
type="button"
className={styles.downloadTitle}
onClick={() =>
navigate(`/game/${game.shop}/${game.objectID}`)
}
>
{game.title}
</button>
<div className={styles.downloadTitleWrapper}>
<button
type="button"
className={styles.downloadTitle}
onClick={() =>
navigate(`/game/${game.shop}/${game.objectID}`)
}
>
{game.title}
</button>
</div>
<small className={styles.downloaderName}>
{downloaderName[game?.downloader]}
</small>
{getGameInfo(game)}
</div>

View File

@ -0,0 +1,95 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const gallerySliderContainer = style({
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
});
export const gallerySliderMedia = style({
width: "100%",
height: "100%",
display: "block",
flexShrink: 0,
flexGrow: "0",
transition: "translate 0.3s ease-in-out",
borderRadius: "4px",
});
export const gallerySliderAnimationContainer = style({
width: "100%",
height: "100%",
display: "flex",
position: "relative",
overflow: "hidden",
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
});
export const gallerySliderPreview = style({
width: "100%",
padding: `${SPACING_UNIT}px 0`,
height: "100%",
display: "flex",
position: "relative",
overflowX: "auto",
overflowY: "hidden",
gap: `${SPACING_UNIT / 2}px`,
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
"::-webkit-scrollbar-thumb": {
width: "20%",
},
"::-webkit-scrollbar": {
height: "10px",
},
});
export const gallerySliderMediaPreview = style({
cursor: "pointer",
width: "20%",
height: "20%",
display: "block",
flexShrink: 0,
flexGrow: 0,
opacity: 0.3,
transition: "translate 0.3s ease-in-out, opacity 0.2s ease",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
":hover": {
opacity: "1",
},
});
export const gallerySliderMediaPreviewActive = style({
opacity: 1,
});
export const gallerySliderButton = style({
all: "unset",
display: "block",
position: "absolute",
top: 0,
bottom: 0,
padding: "1rem",
cursor: "pointer",
transition: "background-color 100ms ease-in-out",
":hover": {
backgroundColor: "rgb(0, 0, 0, 0.2)",
},
});
export const gallerySliderIcons = style({
fill: vars.color.muted,
width: "2rem",
height: "2rem",
});

View File

@ -1,15 +1,15 @@
import { RefObject, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ShopDetails, SteamMovies, SteamScreenshot } from "@types";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import * as styles from "./game-details.css";
import * as styles from "./gallery-slider.css";
export interface GallerySliderProps {
gameDetails: ShopDetails | null;
}
export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollContainerRef: RefObject<HTMLDivElement> =
useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [mediaCount] = useState<number>(() => {
if (gameDetails) {
if (gameDetails.screenshots && gameDetails.movies) {
@ -20,21 +20,13 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
return gameDetails.screenshots.length;
}
}
return 0;
});
const [mediaIndex, setMediaIndex] = useState<number>(0);
const [arrowShow, setArrowShow] = useState(false);
const scrollHorizontallyToPercentage = () => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
const totalWidth = container.scrollWidth - container.clientWidth;
const itemWidth = totalWidth / (mediaCount - 1);
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
}
};
const showNextImage = () => {
setMediaIndex((index: number) => {
if (index === mediaCount - 1) return 0;
@ -42,6 +34,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
return index + 1;
});
};
const showPrevImage = () => {
setMediaIndex((index: number) => {
if (index === 0) return mediaCount - 1;
@ -51,11 +44,25 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
};
useEffect(() => {
scrollHorizontallyToPercentage();
}, [mediaIndex]);
setMediaIndex(0);
}, [gameDetails]);
useEffect(() => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
const totalWidth = container.scrollWidth - container.clientWidth;
const itemWidth = totalWidth / (mediaCount - 1);
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
}
}, [gameDetails, mediaIndex, mediaCount]);
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
const hasMovies = gameDetails && gameDetails.movies?.length;
return (
<>
{gameDetails?.screenshots && (
{hasScreenshots && (
<div className={styles.gallerySliderContainer}>
<div
onMouseEnter={() => setArrowShow(true)}
@ -65,33 +72,43 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
{gameDetails.movies &&
gameDetails.movies.map((video: SteamMovies) => (
<video
key={video.id}
controls
className={styles.gallerySliderMedia}
poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }}
autoPlay
loop
muted
>
<source src={video.webm.max.replace("http", "https")} />
</video>
))}
{gameDetails.screenshots &&
gameDetails.screenshots.map((image: SteamScreenshot) => (
<img
className={styles.gallerySliderMedia}
src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }}
/>
))}
gameDetails.screenshots.map(
(image: SteamScreenshot, i: number) => (
<img
key={"image-" + i}
className={styles.gallerySliderMedia}
src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }}
/>
)
)}
{arrowShow && (
<>
<button
onClick={showPrevImage}
type="button"
className={styles.gallerySliderButton}
style={{ left: 0 }}
>
<ChevronLeftIcon className={styles.gallerySliderIcons} />
</button>
<button
onClick={showNextImage}
type="button"
className={styles.gallerySliderButton}
style={{ right: 0 }}
>
@ -102,9 +119,10 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
</div>
<div className={styles.gallerySliderPreview} ref={scrollContainerRef}>
{gameDetails.movies &&
gameDetails.movies.map((video: SteamMovies, i: number) => (
{hasMovies &&
gameDetails.movies?.map((video: SteamMovies, i: number) => (
<img
key={video.id}
onClick={() => setMediaIndex(i)}
src={video.thumbnail}
className={`${styles.gallerySliderMediaPreview} ${mediaIndex === i ? styles.gallerySliderMediaPreviewActive : ""}`}
@ -114,6 +132,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
gameDetails.screenshots.map(
(image: SteamScreenshot, i: number) => (
<img
key={"image-thumb-" + i}
onClick={() =>
setMediaIndex(
i + (gameDetails.movies ? gameDetails.movies.length : 0)

View File

@ -79,94 +79,6 @@ export const descriptionContent = style({
height: "100%",
});
export const gallerySliderContainer = style({
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
});
export const gallerySliderMedia = style({
width: "100%",
height: "100%",
display: "block",
flexShrink: 0,
flexGrow: 0,
transition: "translate 300ms ease-in-out",
});
export const gallerySliderAnimationContainer = style({
width: "100%",
height: "100%",
display: "flex",
position: "relative",
overflow: "hidden",
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
});
export const gallerySliderPreview = style({
width: "100%",
paddingTop: "0.5rem",
height: "100%",
display: "flex",
position: "relative",
overflowX: "auto",
overflowY: "hidden",
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
"::-webkit-scrollbar-thumb": {
width: "20%"
}
});
export const gallerySliderMediaPreview = style({
cursor: "pointer",
width: "20%",
height: "20%",
display: "block",
flexShrink: 0,
flexGrow: 0,
opacity: 0.3,
paddingRight: "5px",
transition: "translate 300ms ease-in-out",
":hover": {
opacity: 1,
},
});
export const gallerySliderMediaPreviewActive = style({
opacity: 1,
});
export const gallerySliderButton = style({
all: "unset",
display: "block",
position: "absolute",
top: 0,
bottom: 0,
padding: "1rem",
cursor: "pointer",
transition: "background-color 100ms ease-in-out",
":hover": {
backgroundColor: "rgb(0,0,0, 0.2)",
},
});
export const gallerySliderIcons = style({
stroke: "white",
fill: "black",
width: "2rem",
height: "2rem",
});
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
width: "100%",

View File

@ -68,7 +68,7 @@ export function GameDetails() {
const dispatch = useAppDispatch();
const { game: gameDownloading, startDownload, isDownloading } = useDownload();
const { game: gameDownloading, startDownload } = useDownload();
const heroImage = steamUrlBuilder.libraryHero(objectID!);
@ -122,7 +122,7 @@ export function GameDetails() {
setHowLongToBeat({ isLoading: true, data: null });
}, [getGame, dispatch, navigate, objectID, i18n.language]);
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const isGameDownloading = gameDownloading?.id === game?.id;
useEffect(() => {
if (isGameDownloading)

View File

@ -1,3 +1,4 @@
import { GameStatus, GameStatusHelper } from "@shared";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
@ -49,7 +50,7 @@ export function HeroPanelActions({
filters: [
{
name: "Game executable",
extensions: window.electron.platform === "win32" ? ["exe"] : [],
extensions: ["exe"],
},
],
})
@ -152,7 +153,7 @@ export function HeroPanelActions({
);
}
if (game?.status === "paused") {
if (game?.status === GameStatus.Paused) {
return (
<>
<Button
@ -173,10 +174,13 @@ export function HeroPanelActions({
);
}
if (game?.status === "seeding" || (game && !game.status)) {
if (
GameStatusHelper.isReady(game?.status ?? null) ||
(game && !game.status)
) {
return (
<>
{game?.status === "seeding" ? (
{GameStatusHelper.isReady(game?.status ?? null) ? (
<Button
onClick={openGameInstaller}
theme="outline"
@ -212,7 +216,7 @@ export function HeroPanelActions({
);
}
if (game?.status === "cancelled") {
if (game?.status === GameStatus.Cancelled) {
return (
<>
<Button

View File

@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Game } from "@types";
import { useDate } from "@renderer/hooks";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface HeroPanelPlaytimeProps {
game: Game;
isGamePlaying: boolean;
}
export function HeroPanelPlaytime({
game,
isGamePlaying,
}: HeroPanelPlaytimeProps) {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { i18n, t } = useTranslation("game_details");
const { formatDistance } = useDate();
useEffect(() => {
if (game?.lastTimePlayed) {
setLastTimePlayed(
formatDistance(game.lastTimePlayed, new Date(), {
addSuffix: true,
})
);
}
}, [game?.lastTimePlayed, formatDistance]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 1,
});
}, [i18n.language]);
const formatPlayTime = () => {
const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
}
return (
<>
<p>
{t("play_time", {
amount: formatPlayTime(),
})}
</p>
{isGamePlaying ? (
<p>{t("playing_now")}</p>
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
)}
</>
);
}

View File

@ -1,17 +1,17 @@
import { format } from "date-fns";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { useDate } from "@renderer/hooks/use-date";
import { formatBytes } from "@renderer/utils";
import { HeroPanelActions } from "./hero-panel-actions";
import { Downloader, GameStatus, GameStatusHelper, 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";
export interface HeroPanelProps {
game: Game | null;
@ -22,8 +22,6 @@ export interface HeroPanelProps {
getGame: () => void;
}
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export function HeroPanel({
game,
gameDetails,
@ -32,54 +30,22 @@ export function HeroPanel({
getGame,
isGamePlaying,
}: HeroPanelProps) {
const { t, i18n } = useTranslation("game_details");
const { t } = useTranslation("game_details");
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { formatDistance } = useDate();
const {
game: gameDownloading,
isDownloading,
progress,
eta,
numPeers,
numSeeds,
isGameDeleting,
} = useDownload();
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
useEffect(() => {
if (game?.lastTimePlayed) {
setLastTimePlayed(
formatDistance(game.lastTimePlayed, new Date(), {
addSuffix: true,
})
);
}
}, [game?.lastTimePlayed, formatDistance]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 1,
});
}, [i18n]);
const formatPlayTime = () => {
const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
const isGameDownloading =
gameDownloading?.id === game?.id &&
GameStatusHelper.isDownloading(game?.status ?? null);
const finalDownloadSize = useMemo(() => {
if (!game) return "N/A";
@ -106,7 +72,7 @@ export function HeroPanel({
{eta && <small>{t("eta", { eta })}</small>}
</p>
{gameDownloading.status !== "downloading" ? (
{gameDownloading.status !== GameStatus.Downloading ? (
<>
<p>{t(gameDownloading.status)}</p>
{eta && <small>{t("eta", { eta })}</small>}
@ -116,7 +82,8 @@ export function HeroPanel({
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
{finalDownloadSize}
<small>
{numPeers} peers / {numSeeds} seeds
{game?.downloader === Downloader.Torrent &&
`${numPeers} peers / ${numSeeds} seeds`}
</small>
</p>
)}
@ -124,7 +91,7 @@ export function HeroPanel({
);
}
if (game?.status === "paused") {
if (game?.status === GameStatus.Paused) {
return (
<>
<p>
@ -139,30 +106,8 @@ export function HeroPanel({
);
}
if (game?.status === "seeding" || (game && !game.status)) {
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
}
return (
<>
<p>
{t("play_time", {
amount: formatPlayTime(),
})}
</p>
{isGamePlaying ? (
<p>{t("playing_now")}</p>
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
)}
</>
);
if (game && GameStatusHelper.isReady(game?.status ?? null)) {
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
}
const [latestRepack] = gameDetails.repacks;

View File

@ -1,3 +1,4 @@
export const DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY =
"dontShowOnlineFixInstructions";
export const DONT_SHOW_DODI_INSTRUCTIONS_KEY = "dontShowDodiInstructions";

View File

@ -36,7 +36,7 @@ export function RepacksModal({
useEffect(() => {
setFilteredRepacks(gameDetails.repacks);
}, [gameDetails.repacks]);
}, [gameDetails.repacks, visible]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);

View File

@ -17,11 +17,3 @@ export const hintText = style({
fontSize: "12px",
color: vars.color.bodyText,
});
export const settingsLink = style({
textDecoration: "none",
color: "#C0C1C7",
":hover": {
textDecoration: "underline",
},
});

View File

@ -1,13 +1,12 @@
import { Button, Modal, TextField } from "@renderer/components";
import { Button, Link, Modal, TextField } from "@renderer/components";
import { GameRepack, ShopDetails } from "@types";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { formatBytes } from "@renderer/utils";
import { DiskSpace } from "check-disk-space";
import { Link } from "react-router-dom";
import * as styles from "./select-folder-modal.css";
import { DownloadIcon } from "@primer/octicons-react";
import { formatBytes } from "@shared";
export interface SelectFolderModalProps {
visible: boolean;
@ -75,7 +74,7 @@ export function SelectFolderModal({
return (
<Modal
visible={visible}
title={`${gameDetails.name} Installation folder`}
title={t("installation_folder", { name: gameDetails.name })}
description={t("space_left_on_disk", {
space: formatBytes(diskFreeSpace?.free ?? 0),
})}
@ -100,10 +99,9 @@ export function SelectFolderModal({
</Button>
</div>
<p className={styles.hintText}>
{t("select_folder_hint")}{" "}
<Link to="/settings" className={styles.settingsLink}>
{t("settings")}
</Link>
<Trans i18nKey="select_folder_hint" ns="game_details">
<Link to="/settings" />
</Trans>
</p>
<Button onClick={handleStartClick} disabled={downloadStarting}>
<DownloadIcon />

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { UserPreferences } from "@types";
import { CheckboxField } from "@renderer/components";
export interface SettingsBehaviorProps {
userPreferences: UserPreferences | null;
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsBehavior({
updateUserPreferences,
userPreferences,
}: SettingsBehaviorProps) {
const [form, setForm] = useState({
preferQuitInsteadOfHiding: false,
runAtStartup: false,
});
const { t } = useTranslation("settings");
useEffect(() => {
if (userPreferences) {
setForm({
preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding,
runAtStartup: userPreferences.runAtStartup,
});
}
}, [userPreferences]);
const handleChange = (values: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
updateUserPreferences(values);
};
return (
<>
<CheckboxField
label={t("quit_app_instead_hiding")}
checked={form.preferQuitInsteadOfHiding}
onChange={() =>
handleChange({
preferQuitInsteadOfHiding: !form.preferQuitInsteadOfHiding,
})
}
/>
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
handleChange({ runAtStartup: !form.runAtStartup });
window.electron.autoLaunch(!form.runAtStartup);
}}
checked={form.runAtStartup}
/>
</>
);
}

View File

@ -0,0 +1,7 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const downloadsPathField = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { TextField, Button, CheckboxField } from "@renderer/components";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-general.css";
import type { UserPreferences } from "@types";
export interface SettingsGeneralProps {
userPreferences: UserPreferences | null;
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsGeneral({
userPreferences,
updateUserPreferences,
}: SettingsGeneralProps) {
const [form, setForm] = useState({
downloadsPath: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
telemetryEnabled: false,
});
useEffect(() => {
if (userPreferences) {
const {
downloadsPath,
downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled,
telemetryEnabled,
} = userPreferences;
window.electron.getDefaultDownloadsPath().then((defaultDownloadsPath) => {
setForm((prev) => ({
...prev,
downloadsPath: downloadsPath ?? defaultDownloadsPath,
downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled,
telemetryEnabled,
}));
});
}
}, [userPreferences]);
const { t } = useTranslation("settings");
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
defaultPath: form.downloadsPath,
properties: ["openDirectory"],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
updateUserPreferences({ downloadsPath: path });
}
};
const handleChange = (values: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
updateUserPreferences(values);
};
return (
<>
<div className={styles.downloadsPathField}>
<TextField
label={t("downloads_path")}
value={form.downloadsPath}
readOnly
disabled
/>
<Button
style={{ alignSelf: "flex-end" }}
theme="outline"
onClick={handleChooseDownloadsPath}
>
{t("change")}
</Button>
</div>
<h3>{t("notifications")}</h3>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
handleChange({
downloadNotificationsEnabled: !form.downloadNotificationsEnabled,
})
}
/>
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
handleChange({
repackUpdatesNotificationsEnabled:
!form.repackUpdatesNotificationsEnabled,
})
}
/>
<h3>{t("telemetry")}</h3>
<CheckboxField
label={t("telemetry_description")}
checked={form.telemetryEnabled}
onChange={() =>
handleChange({
telemetryEnabled: !form.telemetryEnabled,
})
}
/>
</>
);
}

View File

@ -0,0 +1,9 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const form = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
import * as styles from "./settings-real-debrid.css";
import type { UserPreferences } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken";
export interface SettingsRealDebridProps {
userPreferences: UserPreferences | null;
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsRealDebrid({
userPreferences,
updateUserPreferences,
}: SettingsRealDebridProps) {
const [form, setForm] = useState({
useRealDebrid: false,
realDebridApiToken: null as string | null,
});
const { t } = useTranslation("settings");
useEffect(() => {
if (userPreferences) {
setForm({
useRealDebrid: Boolean(userPreferences.realDebridApiToken),
realDebridApiToken: userPreferences.realDebridApiToken ?? null,
});
}
}, [userPreferences]);
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
updateUserPreferences({
realDebridApiToken: form.useRealDebrid ? form.realDebridApiToken : null,
});
};
const isButtonDisabled = form.useRealDebrid && !form.realDebridApiToken;
return (
<form className={styles.form} onSubmit={handleFormSubmit}>
<CheckboxField
label={t("enable_real_debrid")}
checked={form.useRealDebrid}
onChange={() =>
setForm((prev) => ({
...prev,
useRealDebrid: !form.useRealDebrid,
}))
}
/>
{form.useRealDebrid && (
<TextField
label={t("real_debrid_api_token_description")}
value={form.realDebridApiToken ?? ""}
type="password"
onChange={(event) =>
setForm({ ...form, realDebridApiToken: event.target.value })
}
placeholder="API Token"
containerProps={{ style: { marginTop: `${SPACING_UNIT}px` } }}
hint={
<Trans i18nKey="real_debrid_api_token_hint" ns="settings">
<Link to={REAL_DEBRID_API_TOKEN_URL} />
</Trans>
}
/>
)}
<Button
type="submit"
style={{ alignSelf: "flex-end" }}
disabled={isButtonDisabled}
>
{t("save_changes")}
</Button>
</form>
);
}

View File

@ -20,7 +20,7 @@ export const content = style({
flexDirection: "column",
});
export const downloadsPathField = style({
export const settingsCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});

View File

@ -1,139 +1,76 @@
import { useEffect, useState } from "react";
import { Button, CheckboxField, TextField } from "@renderer/components";
import { Button } from "@renderer/components";
import * as styles from "./settings.css";
import { useTranslation } from "react-i18next";
import { UserPreferences } from "@types";
import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior";
const categories = ["general", "behavior", "real_debrid"];
export function Settings() {
const [form, setForm] = useState({
downloadsPath: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
telemetryEnabled: false,
preferQuitInsteadOfHiding: false,
runAtStartup: false,
});
const [currentCategory, setCurrentCategory] = useState(categories.at(0)!);
const [userPreferences, setUserPreferences] =
useState<UserPreferences | null>(null);
const { t } = useTranslation("settings");
useEffect(() => {
Promise.all([
window.electron.getDefaultDownloadsPath(),
window.electron.getUserPreferences(),
]).then(([path, userPreferences]) => {
setForm({
downloadsPath: userPreferences?.downloadsPath || path,
downloadNotificationsEnabled:
userPreferences?.downloadNotificationsEnabled ?? false,
repackUpdatesNotificationsEnabled:
userPreferences?.repackUpdatesNotificationsEnabled ?? false,
telemetryEnabled: userPreferences?.telemetryEnabled ?? false,
preferQuitInsteadOfHiding:
userPreferences?.preferQuitInsteadOfHiding ?? false,
runAtStartup: userPreferences?.runAtStartup ?? false,
});
window.electron.getUserPreferences().then((userPreferences) => {
setUserPreferences(userPreferences);
});
}, []);
const updateUserPreferences = <T extends keyof UserPreferences>(
field: T,
value: UserPreferences[T]
) => {
setForm((prev) => ({ ...prev, [field]: value }));
window.electron.updateUserPreferences({
[field]: value,
});
const handleUpdateUserPreferences = (values: Partial<UserPreferences>) => {
window.electron.updateUserPreferences(values);
};
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
defaultPath: form.downloadsPath,
properties: ["openDirectory"],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
updateUserPreferences("downloadsPath", path);
const renderCategory = () => {
if (currentCategory === "general") {
return (
<SettingsGeneral
userPreferences={userPreferences}
updateUserPreferences={handleUpdateUserPreferences}
/>
);
}
if (currentCategory === "real_debrid") {
return (
<SettingsRealDebrid
userPreferences={userPreferences}
updateUserPreferences={handleUpdateUserPreferences}
/>
);
}
return (
<SettingsBehavior
userPreferences={userPreferences}
updateUserPreferences={handleUpdateUserPreferences}
/>
);
};
return (
<section className={styles.container}>
<div className={styles.content}>
<div className={styles.downloadsPathField}>
<TextField
label={t("downloads_path")}
value={form.downloadsPath}
readOnly
disabled
/>
<section className={styles.settingsCategories}>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => setCurrentCategory(category)}
>
{t(category)}
</Button>
))}
</section>
<Button
style={{ alignSelf: "flex-end" }}
theme="outline"
onClick={handleChooseDownloadsPath}
>
{t("change")}
</Button>
</div>
<h3>{t("notifications")}</h3>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"downloadNotificationsEnabled",
!form.downloadNotificationsEnabled
)
}
/>
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"repackUpdatesNotificationsEnabled",
!form.repackUpdatesNotificationsEnabled
)
}
/>
<h3>{t("telemetry")}</h3>
<CheckboxField
label={t("telemetry_description")}
checked={form.telemetryEnabled}
onChange={() =>
updateUserPreferences("telemetryEnabled", !form.telemetryEnabled)
}
/>
<h3>{t("behavior")}</h3>
<CheckboxField
label={t("quit_app_instead_hiding")}
checked={form.preferQuitInsteadOfHiding}
onChange={() =>
updateUserPreferences(
"preferQuitInsteadOfHiding",
!form.preferQuitInsteadOfHiding
)
}
/>
<CheckboxField
label={t("launch_with_system")}
onChange={() => {
updateUserPreferences("runAtStartup", !form.runAtStartup);
window.electron.autoLaunch(!form.runAtStartup);
}}
checked={form.runAtStartup}
/>
<h2>{t(currentCategory)}</h2>
{renderCategory()}
</div>
</section>
);

View File

@ -1,15 +0,0 @@
const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
export const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || isNaN(bytes) || bytes <= 0) {
return `0 ${FORMAT[0]}`;
}
const byteKBase = 1024;
const base = Math.floor(Math.log(bytes) / Math.log(byteKBase));
const formatedByte = bytes / byteKBase ** base;
return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`;
};

View File

@ -1 +0,0 @@
export * from "./format-bytes";

52
src/shared/index.ts Normal file
View File

@ -0,0 +1,52 @@
export enum GameStatus {
Seeding = "seeding",
Downloading = "downloading",
Paused = "paused",
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
Decompressing = "decompressing",
Finished = "finished",
}
export enum Downloader {
RealDebrid,
Torrent,
}
const FORMAT = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
export const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || isNaN(bytes) || bytes <= 0) {
return `0 ${FORMAT[0]}`;
}
const byteKBase = 1024;
const base = Math.floor(Math.log(bytes) / Math.log(byteKBase));
const formatedByte = bytes / byteKBase ** base;
return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`;
};
export class GameStatusHelper {
public static isDownloading(status: GameStatus | null) {
return (
status === GameStatus.Downloading ||
status === GameStatus.DownloadingMetadata ||
status === GameStatus.CheckingFiles
);
}
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;
}
}

View File

@ -1,3 +1,5 @@
import type { Downloader, GameStatus } from "@shared";
export type GameShop = "steam" | "epic";
export type CatalogueCategory = "recently_added" | "trending";
@ -14,7 +16,7 @@ export interface SteamScreenshot {
export interface SteamVideoSource {
max: string;
'480': string;
"480": string;
}
export interface SteamMovies {
@ -33,7 +35,7 @@ export interface SteamAppDetails {
short_description: string;
publishers: string[];
genres: SteamGenre[];
movies: SteamMovies[];
movies?: SteamMovies[];
screenshots: SteamScreenshot[];
pc_requirements: {
minimum: string;
@ -90,15 +92,17 @@ export interface Game extends Omit<CatalogueEntry, "cover"> {
id: number;
title: string;
iconUrl: string;
status: string;
status: GameStatus | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
repack: GameRepack;
progress: number;
fileVerificationProgress: number;
decompressionProgress: number;
bytesDownloaded: number;
playTimeInMilliseconds: number;
downloader: Downloader;
executablePath: string | null;
lastTimePlayed: Date | null;
fileSize: number;
@ -120,6 +124,7 @@ export interface UserPreferences {
downloadNotificationsEnabled: boolean;
repackUpdatesNotificationsEnabled: boolean;
telemetryEnabled: boolean;
realDebridApiToken: string | null;
preferQuitInsteadOfHiding: boolean;
runAtStartup: boolean;
}

View File

@ -24,9 +24,12 @@ class Fifo:
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, bytes(msg, "utf-8"))
win32file.WriteFile(self.socket_handle, buffer)
else:
self.socket_handle.send(bytes(msg, "utf-8"))
self.socket_handle.send(buffer)

View File

@ -1,6 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts"],
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/index.ts"],
"compilerOptions": {
"module": "ESNext",
"composite": true,
@ -14,7 +14,8 @@
"@renderer/*": ["src/renderer/*"],
"@types": ["src/types/index.ts"],
"@locales": ["src/locales/index.ts"],
"@resources": ["src/resources/index.ts"]
"@resources": ["src/resources/index.ts"],
"@shared": ["src/shared/index.ts"]
}
}
}

View File

@ -5,7 +5,8 @@
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts",
"src/locales/index.ts"
"src/locales/index.ts",
"src/shared/index.ts"
],
"compilerOptions": {
"composite": true,
@ -16,7 +17,8 @@
"src/renderer/src/*"
],
"@types": ["src/types/index.ts"],
"@locales": ["src/locales/index.ts"]
"@locales": ["src/locales/index.ts"],
"@shared": ["src/shared/index.ts"]
}
}
}

View File

@ -1440,7 +1440,7 @@
dependencies:
undici-types "~5.26.4"
"@types/node@^18.11.18":
"@types/node@^18.11.18", "@types/node@^18.7.13":
version "18.19.31"
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz"
integrity sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==
@ -1523,6 +1523,11 @@
resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.10.tgz#d5a4b56abac169bfbc8b23d291363a682e6fa087"
integrity sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==
"@types/when@^2.4.34":
version "2.4.41"
resolved "https://registry.yarnpkg.com/@types/when/-/when-2.4.41.tgz#e16e685aa739c696a582b10afc5f1306964846a2"
integrity sha512-o/j5X9Bnv6mMG4ZcNJur8UaU17Rl0mLbTZvWcODVVy+Xdh8LEc7s6I0CvbEuTP786LTa0OyJby5P4hI7C+ZJNg==
"@types/yauzl@^2.9.1":
version "2.10.3"
resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz"
@ -2332,7 +2337,7 @@ color-name@~1.1.4:
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.6.0:
color-string@^1.6.0, color-string@^1.9.0:
version "1.9.1"
resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz"
integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
@ -2353,6 +2358,14 @@ color@^3.1.3:
color-convert "^1.9.3"
color-string "^1.6.0"
color@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a"
integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
dependencies:
color-convert "^2.0.1"
color-string "^1.9.0"
colorspace@1.1.x:
version "1.1.4"
resolved "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz"
@ -2679,6 +2692,11 @@ eastasianwidth@^0.2.0:
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
easydl@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/easydl/-/easydl-1.1.1.tgz"
integrity sha512-DOInkODIEh7Z6areIv33eIo7ZXH7RulEvi7tZex4k1AmL0p1ODKH9/4rOCEGafUCZ/H/cbR701L27RT2RPe2/w==
ejs@^3.1.8:
version "3.1.10"
resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz"
@ -4362,7 +4380,12 @@ minimatch@^8.0.2:
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6:
minimist@1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6, minimist@^1.2.8:
version "1.2.8"
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@ -4471,6 +4494,20 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
node-7z-archive@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/node-7z-archive/-/node-7z-archive-1.1.7.tgz#0b037701e016a651d6040b63d8781b2e7102facd"
integrity sha512-gtpWpajFyzeObGiYI9RDq76x5ULnxInvZ1OfA0/MD+2VezcMmMQMK6ITqkvsGEqVy4w/psvmIyowVDoSURAJHg==
dependencies:
fs-extra "^10.1.0"
minimist "^1.2.8"
node-sys "^1.2.2"
node-unar "^1.0.8"
node-wget-fetch "^1.1.3"
when "^3.7.8"
optionalDependencies:
"@types/when" "^2.4.34"
node-abi@^3.3.0:
version "3.62.0"
resolved "https://registry.npmjs.org/node-abi/-/node-abi-3.62.0.tgz"
@ -4509,6 +4546,40 @@ node-releases@^2.0.14:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz"
integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
node-stream-zip@^1.12.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/node-stream-zip/-/node-stream-zip-1.15.0.tgz#158adb88ed8004c6c49a396b50a6a5de3bca33ea"
integrity sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==
node-sys@^1.1.7, node-sys@^1.2.2:
version "1.2.4"
resolved "https://registry.yarnpkg.com/node-sys/-/node-sys-1.2.4.tgz#db9c50fd93c8fc62bc4eafe93eae0fd3696c8028"
integrity sha512-71sIz+zgaHfSmP1vHTHXUVb77PqncIB1MBij+Q43fQSz7ceSLrrO5RTTBlnYWDU/M0fEFTZw3Zui/lVeJvoeag==
dependencies:
minimist "1.2.6"
which "^2.0.2"
optionalDependencies:
"@types/node" "^18.7.13"
node-unar@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/node-unar/-/node-unar-1.0.8.tgz#fbf5b05da2ac24278b6160f3b46231d56a73a673"
integrity sha512-AnEdWmV8/Dx1qMB5O2VcemoBmNzW1mhibYNl3YDUI7cVohVuobuIZwxrtRedItO05A6PiLp/HNw1ryg7M17H5g==
dependencies:
node-sys "^1.1.7"
when "^3.7.8"
optionalDependencies:
fs-extra "^9.0.1"
node-stream-zip "^1.12.0"
node-wget-fetch "^1.1.2"
node-wget-fetch@^1.1.2, node-wget-fetch@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/node-wget-fetch/-/node-wget-fetch-1.1.3.tgz#1e4aea2d7093393a961bb9c07cf5c5e33913c437"
integrity sha512-TmjZeeL/zAcB4fpok2iJ6FLbjVzSsjKi7rdk0womqvUY2ouitsEN0kGekndshaB7ENnXocrcgUudpvB4Jo3+LA==
dependencies:
node-fetch "^2.6.7"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@ -5951,6 +6022,11 @@ whatwg-url@^5.0.0:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
when@^3.7.8:
version "3.7.8"
resolved "https://registry.yarnpkg.com/when/-/when-3.7.8.tgz#c7130b6a7ea04693e842cdc9e7a1f2aa39a39f82"
integrity sha512-5cZ7mecD3eYcMiCH4wtRPA5iFJZ50BJYDfckI5RRpQiktMiYTcn0ccLTZOvcbBume+1304fQztxeNzNS9Gvrnw==
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"