mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-26 04:11:02 +00:00
Compare commits
200 Commits
feat/LBX-3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c2e74013 | ||
|
|
17528e74bb | ||
|
|
355993b954 | ||
|
|
4aaaecbee5 | ||
|
|
802b4bd26b | ||
|
|
baa2c8471a | ||
|
|
63bb5ca511 | ||
|
|
073d3f25e3 | ||
|
|
066185e6ee | ||
|
|
8ce4b59294 | ||
|
|
90b62e4e8d | ||
|
|
8a447f683a | ||
|
|
5ddfd88ef7 | ||
|
|
569ad1c862 | ||
|
|
039df43123 | ||
|
|
50bafbb7f6 | ||
|
|
46154fa49a | ||
|
|
aae35b591d | ||
|
|
10ac6c9d9c | ||
|
|
9ca6a114b1 | ||
|
|
2108a523bc | ||
|
|
fbbb2520e0 | ||
|
|
049a989e85 | ||
|
|
88b2581797 | ||
|
|
c9801644ac | ||
|
|
98cfe7be98 | ||
|
|
128f864ca7 | ||
|
|
a2b993bb9b | ||
|
|
7293afb618 | ||
|
|
194e7918ca | ||
|
|
979958aca6 | ||
|
|
cafa536f79 | ||
|
|
6e92e0f79f | ||
|
|
aef069d4c7 | ||
|
|
1f447cc478 | ||
|
|
5d2dc3616c | ||
|
|
1f9972f74e | ||
|
|
3344f68408 | ||
|
|
65be11cc07 | ||
|
|
7e78a0f9f1 | ||
|
|
d56cc8695b | ||
|
|
3f7b9e2a0b | ||
|
|
3b3926156e | ||
|
|
0089e08ba7 | ||
|
|
bced12d077 | ||
|
|
f8ba72a0e2 | ||
|
|
2029f861f6 | ||
|
|
46e248c62a | ||
|
|
dba8f9fb22 | ||
|
|
b565ef7f00 | ||
|
|
9298d9aa09 | ||
|
|
5b05fc2644 | ||
|
|
605d064ec0 | ||
|
|
c0956b1bc1 | ||
|
|
2e152d321e | ||
|
|
a553b049ba | ||
|
|
467b27baa3 | ||
|
|
4342a4a5d5 | ||
|
|
d9d443ee6d | ||
|
|
a912b57ccc | ||
|
|
447c146035 | ||
|
|
39ff44f9d1 | ||
|
|
dbe101b7df | ||
|
|
5e4e03a958 | ||
|
|
da0ae54b60 | ||
|
|
562e30eecf | ||
|
|
e7a62c16fa | ||
|
|
ed044d797f | ||
|
|
ed3cce160f | ||
|
|
c67b275657 | ||
|
|
a2e866317d | ||
|
|
a7c82de4a7 | ||
|
|
027761a1b5 | ||
|
|
ca2f70aede | ||
|
|
2b3a8bf6b6 | ||
|
|
81b3ad3612 | ||
|
|
8f477072ba | ||
|
|
569700e85c | ||
|
|
4975f2def9 | ||
|
|
77af7509ac | ||
|
|
f37ccbb4c0 | ||
|
|
feb8d78e01 | ||
|
|
44f39d94c4 | ||
|
|
e618d313b3 | ||
|
|
7eec87c192 | ||
|
|
96140e614c | ||
|
|
2ccc93ea61 | ||
|
|
7e7390885e | ||
|
|
64815f4f8d | ||
|
|
4dfdc4d798 | ||
|
|
9bbfab2aff | ||
|
|
01938f8905 | ||
|
|
f60ad5908d | ||
|
|
fe6553bcdc | ||
|
|
87895bb715 | ||
|
|
290209f372 | ||
|
|
87fcbaa56e | ||
|
|
c32ce14630 | ||
|
|
e52f10a5ff | ||
|
|
bcdbe31596 | ||
|
|
7ed514b6ef | ||
|
|
42386ae0b5 | ||
|
|
04d8f900a6 | ||
|
|
8b3bcd88b1 | ||
|
|
b2bffeb2b0 | ||
|
|
0a194eaa29 | ||
|
|
07c277c033 | ||
|
|
345696ad06 | ||
|
|
6c4e8c406f | ||
|
|
c46a1e7848 | ||
|
|
590e09a8c3 | ||
|
|
c1d7ea27f3 | ||
|
|
15dbd3b2ad | ||
|
|
4584783f44 | ||
|
|
765ec70dd0 | ||
|
|
de483da51c | ||
|
|
2bc0266775 | ||
|
|
c9729fb3eb | ||
|
|
9a7ad148e3 | ||
|
|
d929fbaeaa | ||
|
|
8fa33119d6 | ||
|
|
92d87c5d33 | ||
|
|
af884d3772 | ||
|
|
dc31ac0831 | ||
|
|
9769eecec6 | ||
|
|
91adb97013 | ||
|
|
f138b2efcb | ||
|
|
991aa05760 | ||
|
|
aff9e13bca | ||
|
|
240a75c1d0 | ||
|
|
edbe86a1fb | ||
|
|
a01e1b1709 | ||
|
|
60fd90820c | ||
|
|
798f88618e | ||
|
|
40795c34dc | ||
|
|
e335e05628 | ||
|
|
05464f25df | ||
|
|
b9830afca1 | ||
|
|
1cab73bcb4 | ||
|
|
27462c1e1e | ||
|
|
98dc20092a | ||
|
|
9faf34a976 | ||
|
|
d25ac69e74 | ||
|
|
d3fb967229 | ||
|
|
35736dd2d9 | ||
|
|
263d0be4e4 | ||
|
|
0b4d31e482 | ||
|
|
a74b557d13 | ||
|
|
86d5547aa1 | ||
|
|
358f41b4ba | ||
|
|
7f0dc5eee4 | ||
|
|
067f7a00be | ||
|
|
37f085e2c0 | ||
|
|
f8ac284bc2 | ||
|
|
cea5afc7f7 | ||
|
|
dff8d02f3f | ||
|
|
8751e369da | ||
|
|
45eaef23a9 | ||
|
|
3c296fe721 | ||
|
|
1d1bbd2de5 | ||
|
|
246fc14b75 | ||
|
|
387b3ebeac | ||
|
|
b1d72828bb | ||
|
|
4c09f915c6 | ||
|
|
24d65b50b4 | ||
|
|
f77b2116c1 | ||
|
|
6cd65d6239 | ||
|
|
34681b3bc2 | ||
|
|
4b71a3f5bb | ||
|
|
a0f669c97b | ||
|
|
96fca88601 | ||
|
|
3f65bb86a8 | ||
|
|
b494c7c8ec | ||
|
|
cf16c8245c | ||
|
|
6257529297 | ||
|
|
b8352be274 | ||
|
|
d5e6bed3b7 | ||
|
|
214267df7e | ||
|
|
21f46c9af3 | ||
|
|
cc5e0014f7 | ||
|
|
ccb754fa13 | ||
|
|
142bd3156c | ||
|
|
95a7bc2236 | ||
|
|
78d2be85f2 | ||
|
|
67ea9e78a2 | ||
|
|
67f863e0f3 | ||
|
|
77b6f1b2ad | ||
|
|
5329cc446f | ||
|
|
21a0ad1500 | ||
|
|
9ffaee12d1 | ||
|
|
8555274589 | ||
|
|
a152c89d7f | ||
|
|
879f0baad7 | ||
|
|
c025dc199d | ||
|
|
5b4b258526 | ||
|
|
0268829946 | ||
|
|
3f41f0f7ad | ||
|
|
1545f42d17 | ||
|
|
2adc132c33 | ||
|
|
c4852b89f1 |
@@ -1,6 +1,7 @@
|
|||||||
MAIN_VITE_API_URL=
|
MAIN_VITE_API_URL=
|
||||||
MAIN_VITE_AUTH_URL=
|
MAIN_VITE_AUTH_URL=
|
||||||
MAIN_VITE_WS_URL=
|
MAIN_VITE_WS_URL=
|
||||||
|
MAIN_VITE_NIMBUS_API_URL=
|
||||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
||||||
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
||||||
MAIN_VITE_LAUNCHER_SUBDOMAIN=
|
MAIN_VITE_LAUNCHER_SUBDOMAIN=
|
||||||
|
|||||||
1
.github/workflows/build-renderer.yml
vendored
1
.github/workflows/build-renderer.yml
vendored
@@ -42,6 +42,7 @@ jobs:
|
|||||||
run: yarn build
|
run: yarn build
|
||||||
env:
|
env:
|
||||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
|
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
- name: Deploy to Cloudflare Pages
|
||||||
env:
|
env:
|
||||||
|
|||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -57,6 +57,7 @@ jobs:
|
|||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
||||||
|
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -73,6 +74,7 @@ jobs:
|
|||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
||||||
|
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -54,9 +54,10 @@ jobs:
|
|||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||||
|
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||||
|
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
@@ -71,9 +72,10 @@ jobs:
|
|||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||||
|
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||||
|
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
[](https://github.com/hydralauncher/hydra/actions)
|
[](https://github.com/hydralauncher/hydra/actions)
|
||||||
[](https://github.com/hydralauncher/hydra/releases)
|
[](https://github.com/hydralauncher/hydra/releases)
|
||||||
|
[](https://community.chocolatey.org/packages/hydralauncher)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "3.7.6",
|
"version": "3.8.1",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@primer/octicons-react": "^19.9.0",
|
"@primer/octicons-react": "^19.9.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
|
"@sentry/react": "^10.33.0",
|
||||||
"@tiptap/extension-bold": "^3.6.2",
|
"@tiptap/extension-bold": "^3.6.2",
|
||||||
"@tiptap/extension-italic": "^3.6.2",
|
"@tiptap/extension-italic": "^3.6.2",
|
||||||
"@tiptap/extension-link": "^3.6.2",
|
"@tiptap/extension-link": "^3.6.2",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"file-type": "^20.5.0",
|
"file-type": "^20.5.0",
|
||||||
"framer-motion": "^12.15.0",
|
"framer-motion": "^12.15.0",
|
||||||
|
"get-port": "^7.1.0",
|
||||||
"hls.js": "^1.5.12",
|
"hls.js": "^1.5.12",
|
||||||
"i18next": "^23.11.2",
|
"i18next": "^23.11.2",
|
||||||
"i18next-browser-languagedetector": "^7.2.1",
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
"user-agents": "^1.1.387",
|
"user-agents": "^1.1.387",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winreg": "^1.2.5",
|
"winreg": "^1.2.5",
|
||||||
|
"workwonders-sdk": "0.1.1",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"yaml": "^2.6.1",
|
"yaml": "^2.6.1",
|
||||||
"yup": "^1.5.0"
|
"yup": "^1.5.0"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import aria2p
|
import aria2p
|
||||||
|
from aria2p.client import ClientException as DownloadNotFound
|
||||||
|
|
||||||
class HttpDownloader:
|
class HttpDownloader:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -11,12 +12,16 @@ class HttpDownloader:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_download(self, url: str, save_path: str, header: str, out: str = None):
|
def start_download(self, url: str, save_path: str, header, out: str = None):
|
||||||
if self.download:
|
if self.download:
|
||||||
self.aria2.resume([self.download])
|
self.aria2.resume([self.download])
|
||||||
else:
|
else:
|
||||||
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
|
options = {"dir": save_path}
|
||||||
|
if header:
|
||||||
|
options["header"] = header
|
||||||
|
if out:
|
||||||
|
options["out"] = out
|
||||||
|
downloads = self.aria2.add(url, options=options)
|
||||||
self.download = downloads[0]
|
self.download = downloads[0]
|
||||||
|
|
||||||
def pause_download(self):
|
def pause_download(self):
|
||||||
@@ -32,7 +37,11 @@ class HttpDownloader:
|
|||||||
if self.download == None:
|
if self.download == None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
download = self.aria2.get_download(self.download.gid)
|
download = self.aria2.get_download(self.download.gid)
|
||||||
|
except DownloadNotFound:
|
||||||
|
self.download = None
|
||||||
|
return None
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
'folderName': download.name,
|
'folderName': download.name,
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import aria2p
|
|
||||||
from aria2p.client import ClientException as DownloadNotFound
|
|
||||||
|
|
||||||
class HttpMultiLinkDownloader:
|
|
||||||
def __init__(self):
|
|
||||||
self.downloads = []
|
|
||||||
self.completed_downloads = []
|
|
||||||
self.total_size = None
|
|
||||||
self.aria2 = aria2p.API(
|
|
||||||
aria2p.Client(
|
|
||||||
host="http://localhost",
|
|
||||||
port=6800,
|
|
||||||
secret=""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
|
|
||||||
"""Add multiple URLs to download queue with same options"""
|
|
||||||
options = {"dir": save_path}
|
|
||||||
if header:
|
|
||||||
options["header"] = header
|
|
||||||
if out:
|
|
||||||
options["out"] = out
|
|
||||||
|
|
||||||
# Clear any existing downloads first
|
|
||||||
self.cancel_download()
|
|
||||||
self.completed_downloads = []
|
|
||||||
self.total_size = total_size
|
|
||||||
|
|
||||||
for url in urls:
|
|
||||||
try:
|
|
||||||
added_downloads = self.aria2.add(url, options=options)
|
|
||||||
self.downloads.extend(added_downloads)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error adding download for URL {url}: {str(e)}")
|
|
||||||
|
|
||||||
def pause_download(self):
|
|
||||||
"""Pause all active downloads"""
|
|
||||||
if self.downloads:
|
|
||||||
try:
|
|
||||||
self.aria2.pause(self.downloads)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error pausing downloads: {str(e)}")
|
|
||||||
|
|
||||||
def cancel_download(self):
|
|
||||||
"""Cancel and remove all downloads"""
|
|
||||||
if self.downloads:
|
|
||||||
try:
|
|
||||||
# First try to stop the downloads
|
|
||||||
self.aria2.remove(self.downloads)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error removing downloads: {str(e)}")
|
|
||||||
finally:
|
|
||||||
# Clear the downloads list regardless of success/failure
|
|
||||||
self.downloads = []
|
|
||||||
self.completed_downloads = []
|
|
||||||
|
|
||||||
def get_download_status(self):
|
|
||||||
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
|
|
||||||
if not self.downloads and not self.completed_downloads:
|
|
||||||
return []
|
|
||||||
|
|
||||||
total_completed = 0
|
|
||||||
current_download_speed = 0
|
|
||||||
active_downloads = []
|
|
||||||
to_remove = []
|
|
||||||
|
|
||||||
# First calculate sizes from completed downloads
|
|
||||||
for completed in self.completed_downloads:
|
|
||||||
total_completed += completed['size']
|
|
||||||
|
|
||||||
# Then check active downloads
|
|
||||||
for download in self.downloads:
|
|
||||||
try:
|
|
||||||
current_download = self.aria2.get_download(download.gid)
|
|
||||||
|
|
||||||
# Skip downloads that are not properly initialized
|
|
||||||
if not current_download or not current_download.files:
|
|
||||||
to_remove.append(download)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add to completed size and speed calculations
|
|
||||||
total_completed += current_download.completed_length
|
|
||||||
current_download_speed += current_download.download_speed
|
|
||||||
|
|
||||||
# If download is complete, move it to completed_downloads
|
|
||||||
if current_download.status == 'complete':
|
|
||||||
self.completed_downloads.append({
|
|
||||||
'name': current_download.name,
|
|
||||||
'size': current_download.total_length
|
|
||||||
})
|
|
||||||
to_remove.append(download)
|
|
||||||
else:
|
|
||||||
active_downloads.append({
|
|
||||||
'name': current_download.name,
|
|
||||||
'size': current_download.total_length,
|
|
||||||
'completed': current_download.completed_length,
|
|
||||||
'speed': current_download.download_speed
|
|
||||||
})
|
|
||||||
|
|
||||||
except DownloadNotFound:
|
|
||||||
to_remove.append(download)
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting download status: {str(e)}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Clean up completed/removed downloads from active list
|
|
||||||
for download in to_remove:
|
|
||||||
try:
|
|
||||||
if download in self.downloads:
|
|
||||||
self.downloads.remove(download)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Return aggregate status
|
|
||||||
if self.total_size or active_downloads or self.completed_downloads:
|
|
||||||
# Use the first active download's name as the folder name, or completed if none active
|
|
||||||
folder_name = None
|
|
||||||
if active_downloads:
|
|
||||||
folder_name = active_downloads[0]['name']
|
|
||||||
elif self.completed_downloads:
|
|
||||||
folder_name = self.completed_downloads[0]['name']
|
|
||||||
|
|
||||||
if folder_name and '/' in folder_name:
|
|
||||||
folder_name = folder_name.split('/')[0]
|
|
||||||
|
|
||||||
# Use provided total size if available, otherwise sum from downloads
|
|
||||||
total_size = self.total_size
|
|
||||||
if not total_size:
|
|
||||||
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
|
|
||||||
|
|
||||||
# Calculate completion status based on total downloaded vs total size
|
|
||||||
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
|
|
||||||
|
|
||||||
# If all downloads are complete, clear the completed_downloads list to prevent status updates
|
|
||||||
if is_complete:
|
|
||||||
self.completed_downloads = []
|
|
||||||
|
|
||||||
return [{
|
|
||||||
'folderName': folder_name,
|
|
||||||
'fileSize': total_size,
|
|
||||||
'progress': total_completed / total_size if total_size > 0 else 0,
|
|
||||||
'downloadSpeed': current_download_speed,
|
|
||||||
'numPeers': 0,
|
|
||||||
'numSeeds': 0,
|
|
||||||
'status': 'complete' if is_complete else 'active',
|
|
||||||
'bytesDownloaded': total_completed,
|
|
||||||
}]
|
|
||||||
|
|
||||||
return []
|
|
||||||
@@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil
|
|||||||
from torrent_downloader import TorrentDownloader
|
from torrent_downloader import TorrentDownloader
|
||||||
from http_downloader import HttpDownloader
|
from http_downloader import HttpDownloader
|
||||||
from profile_image_processor import ProfileImageProcessor
|
from profile_image_processor import ProfileImageProcessor
|
||||||
from http_multi_link_downloader import HttpMultiLinkDownloader
|
|
||||||
import libtorrent as lt
|
import libtorrent as lt
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -25,15 +24,7 @@ if start_download_payload:
|
|||||||
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
||||||
downloading_game_id = initial_download['game_id']
|
downloading_game_id = initial_download['game_id']
|
||||||
|
|
||||||
if isinstance(initial_download['url'], list):
|
if initial_download['url'].startswith('magnet'):
|
||||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
|
||||||
http_multi_downloader = HttpMultiLinkDownloader()
|
|
||||||
downloads[initial_download['game_id']] = http_multi_downloader
|
|
||||||
try:
|
|
||||||
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
|
|
||||||
except Exception as e:
|
|
||||||
print("Error starting multi-link download", e)
|
|
||||||
elif initial_download['url'].startswith('magnet'):
|
|
||||||
torrent_downloader = TorrentDownloader(torrent_session)
|
torrent_downloader = TorrentDownloader(torrent_session)
|
||||||
downloads[initial_download['game_id']] = torrent_downloader
|
downloads[initial_download['game_id']] = torrent_downloader
|
||||||
try:
|
try:
|
||||||
@@ -78,14 +69,6 @@ def status():
|
|||||||
if not status:
|
if not status:
|
||||||
return jsonify(None)
|
return jsonify(None)
|
||||||
|
|
||||||
if isinstance(status, list):
|
|
||||||
if not status: # Empty list
|
|
||||||
return jsonify(None)
|
|
||||||
|
|
||||||
# For multi-link downloader, use the aggregated status
|
|
||||||
# The status will already be aggregated by the HttpMultiLinkDownloader
|
|
||||||
return jsonify(status[0]), 200
|
|
||||||
|
|
||||||
return jsonify(status), 200
|
return jsonify(status), 200
|
||||||
|
|
||||||
@app.route("/seed-status", methods=["GET"])
|
@app.route("/seed-status", methods=["GET"])
|
||||||
@@ -104,21 +87,7 @@ def seed_status():
|
|||||||
if not response:
|
if not response:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(response, list):
|
if response.get('status') == 5: # Torrent seeding check
|
||||||
# For multi-link downloader, check if all files are complete
|
|
||||||
if response and all(item['status'] == 'complete' for item in response):
|
|
||||||
seed_status.append({
|
|
||||||
'gameId': game_id,
|
|
||||||
'status': 'complete',
|
|
||||||
'folderName': response[0]['folderName'],
|
|
||||||
'fileSize': sum(item['fileSize'] for item in response),
|
|
||||||
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
|
|
||||||
'downloadSpeed': 0,
|
|
||||||
'numPeers': 0,
|
|
||||||
'numSeeds': 0,
|
|
||||||
'progress': 1.0
|
|
||||||
})
|
|
||||||
elif response.get('status') == 5: # Original torrent seeding check
|
|
||||||
seed_status.append({
|
seed_status.append({
|
||||||
'gameId': game_id,
|
'gameId': game_id,
|
||||||
**response,
|
**response,
|
||||||
@@ -180,15 +149,7 @@ def action():
|
|||||||
|
|
||||||
existing_downloader = downloads.get(game_id)
|
existing_downloader = downloads.get(game_id)
|
||||||
|
|
||||||
if isinstance(url, list):
|
if url.startswith('magnet'):
|
||||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
|
||||||
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
|
|
||||||
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
|
||||||
else:
|
|
||||||
http_multi_downloader = HttpMultiLinkDownloader()
|
|
||||||
downloads[game_id] = http_multi_downloader
|
|
||||||
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
|
||||||
elif url.startswith('magnet'):
|
|
||||||
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
||||||
existing_downloader.start_download(url, data['save_path'])
|
existing_downloader.start_download(url, data['save_path'])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"game_has_no_executable": "Game has no executable selected",
|
"game_has_no_executable": "Game has no executable selected",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
|
"notifications": "Notifications",
|
||||||
"need_help": "Need help?",
|
"need_help": "Need help?",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"playable_button_title": "Show only games you can play now",
|
"playable_button_title": "Show only games you can play now",
|
||||||
@@ -107,7 +108,17 @@
|
|||||||
"search_results": "Search results",
|
"search_results": "Search results",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"version_available_install": "Version {{version}} available. Click here to restart and install.",
|
"version_available_install": "Version {{version}} available. Click here to restart and install.",
|
||||||
"version_available_download": "Version {{version}} available. Click here to download."
|
"version_available_download": "Version {{version}} available. Click here to download.",
|
||||||
|
"scan_games_tooltip": "Scan PC for installed games",
|
||||||
|
"scan_games_title": "Scan PC for installed games",
|
||||||
|
"scan_games_description": "This will scan your disks for known game executables. This may take several minutes.",
|
||||||
|
"scan_games_start": "Start Scan",
|
||||||
|
"scan_games_cancel": "Cancel",
|
||||||
|
"scan_games_result": "Found {{found}} of {{total}} games without executable path",
|
||||||
|
"scan_games_no_results": "We couldn't find any installed games.",
|
||||||
|
"scan_games_in_progress": "Scanning your disks for installed games...",
|
||||||
|
"scan_games_close": "Close",
|
||||||
|
"scan_games_scan_again": "Scan Again"
|
||||||
},
|
},
|
||||||
"bottom_panel": {
|
"bottom_panel": {
|
||||||
"no_downloads_in_progress": "No downloads in progress",
|
"no_downloads_in_progress": "No downloads in progress",
|
||||||
@@ -174,6 +185,7 @@
|
|||||||
"repacks_modal_description": "Choose the repack you want to download",
|
"repacks_modal_description": "Choose the repack you want to download",
|
||||||
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
|
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
|
||||||
"download_now": "Download now",
|
"download_now": "Download now",
|
||||||
|
"loading": "Loading...",
|
||||||
"no_shop_details": "Could not retrieve shop details.",
|
"no_shop_details": "Could not retrieve shop details.",
|
||||||
"download_options": "Download options",
|
"download_options": "Download options",
|
||||||
"download_path": "Download path",
|
"download_path": "Download path",
|
||||||
@@ -183,6 +195,12 @@
|
|||||||
"open_screenshot": "Open screenshot {{number}}",
|
"open_screenshot": "Open screenshot {{number}}",
|
||||||
"download_settings": "Download settings",
|
"download_settings": "Download settings",
|
||||||
"downloader": "Downloader",
|
"downloader": "Downloader",
|
||||||
|
"downloader_online": "Online",
|
||||||
|
"downloader_not_configured": "Available but not configured",
|
||||||
|
"downloader_offline": "Link is offline",
|
||||||
|
"downloader_not_available": "Not available",
|
||||||
|
"recommended": "Recommended",
|
||||||
|
"go_to_settings": "Go to Settings",
|
||||||
"select_executable": "Select",
|
"select_executable": "Select",
|
||||||
"no_executable_selected": "No executable selected",
|
"no_executable_selected": "No executable selected",
|
||||||
"open_folder": "Open folder",
|
"open_folder": "Open folder",
|
||||||
@@ -364,6 +382,9 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"filter_by_source": "Filter by source",
|
"filter_by_source": "Filter by source",
|
||||||
"no_repacks_found": "No sources found for this game",
|
"no_repacks_found": "No sources found for this game",
|
||||||
|
"source_online": "Source is online",
|
||||||
|
"source_partial": "Some links are offline",
|
||||||
|
"source_offline": "Source is offline",
|
||||||
"delete_review": "Delete review",
|
"delete_review": "Delete review",
|
||||||
"remove_review": "Remove Review",
|
"remove_review": "Remove Review",
|
||||||
"delete_review_modal_title": "Are you sure you want to delete your review?",
|
"delete_review_modal_title": "Are you sure you want to delete your review?",
|
||||||
@@ -396,6 +417,10 @@
|
|||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"removed": "Not downloaded",
|
"removed": "Not downloaded",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"cancel_download": "Cancel download?",
|
||||||
|
"cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.",
|
||||||
|
"keep_downloading": "No, keep downloading",
|
||||||
|
"yes_cancel": "Yes, cancel",
|
||||||
"filter": "Filter downloaded games",
|
"filter": "Filter downloaded games",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"downloading_metadata": "Downloading metadata…",
|
"downloading_metadata": "Downloading metadata…",
|
||||||
@@ -420,7 +445,9 @@
|
|||||||
"delete_archive_title": "Would you like to delete {{fileName}}?",
|
"delete_archive_title": "Would you like to delete {{fileName}}?",
|
||||||
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
|
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No",
|
||||||
|
"network": "NETWORK",
|
||||||
|
"peak": "PEAK"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Downloads path",
|
"downloads_path": "Downloads path",
|
||||||
@@ -556,6 +583,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
|
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
|
||||||
"extract_files_by_default": "Extract files by default after download",
|
"extract_files_by_default": "Extract files by default after download",
|
||||||
"enable_steam_achievements": "Enable search for Steam achievements",
|
"enable_steam_achievements": "Enable search for Steam achievements",
|
||||||
|
"enable_new_download_options_badges": "Show new download options badges",
|
||||||
"achievement_custom_notification_position": "Achievement custom notification position",
|
"achievement_custom_notification_position": "Achievement custom notification position",
|
||||||
"top-left": "Top left",
|
"top-left": "Top left",
|
||||||
"top-center": "Top center",
|
"top-center": "Top center",
|
||||||
@@ -583,7 +611,10 @@
|
|||||||
"notification_preview": "Achievement Notification Preview",
|
"notification_preview": "Achievement Notification Preview",
|
||||||
"enable_friend_start_game_notifications": "When a friend starts playing a game",
|
"enable_friend_start_game_notifications": "When a friend starts playing a game",
|
||||||
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
|
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
|
||||||
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup"
|
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
|
||||||
|
"downloads": "Downloads",
|
||||||
|
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
|
||||||
|
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download complete",
|
"download_complete": "Download complete",
|
||||||
@@ -601,7 +632,11 @@
|
|||||||
"game_extracted": "{{title}} extracted successfully",
|
"game_extracted": "{{title}} extracted successfully",
|
||||||
"friend_started_playing_game": "{{displayName}} started playing a game",
|
"friend_started_playing_game": "{{displayName}} started playing a game",
|
||||||
"test_achievement_notification_title": "This is a test notification",
|
"test_achievement_notification_title": "This is a test notification",
|
||||||
"test_achievement_notification_description": "Pretty cool, huh?"
|
"test_achievement_notification_description": "Pretty cool, huh?",
|
||||||
|
"scan_games_complete_title": "Scanning for games finished successfully",
|
||||||
|
"scan_games_complete_description": "Found {{count}} games without executable path set",
|
||||||
|
"scan_games_no_results_title": "Scanning for games finished",
|
||||||
|
"scan_games_no_results_description": "No installed games were found"
|
||||||
},
|
},
|
||||||
"system_tray": {
|
"system_tray": {
|
||||||
"open": "Open Hydra",
|
"open": "Open Hydra",
|
||||||
@@ -660,6 +695,7 @@
|
|||||||
"sending": "Sending",
|
"sending": "Sending",
|
||||||
"friend_request_sent": "Friend request sent",
|
"friend_request_sent": "Friend request sent",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
|
"badges": "Badges",
|
||||||
"friends_list": "Friends list",
|
"friends_list": "Friends list",
|
||||||
"user_not_found": "User not found",
|
"user_not_found": "User not found",
|
||||||
"block_user": "Block user",
|
"block_user": "Block user",
|
||||||
@@ -670,12 +706,17 @@
|
|||||||
"ignore_request": "Ignore request",
|
"ignore_request": "Ignore request",
|
||||||
"cancel_request": "Cancel request",
|
"cancel_request": "Cancel request",
|
||||||
"undo_friendship": "Undo friendship",
|
"undo_friendship": "Undo friendship",
|
||||||
|
"friendship_removed": "Friend removed",
|
||||||
"request_accepted": "Request accepted",
|
"request_accepted": "Request accepted",
|
||||||
"user_blocked_successfully": "User blocked successfully",
|
"user_blocked_successfully": "User blocked successfully",
|
||||||
"user_block_modal_text": "This will block {{displayName}}",
|
"user_block_modal_text": "This will block {{displayName}}",
|
||||||
"blocked_users": "Blocked users",
|
"blocked_users": "Blocked users",
|
||||||
"unblock": "Unblock",
|
"unblock": "Unblock",
|
||||||
"no_friends_added": "You have no added friends",
|
"no_friends_added": "You have no added friends",
|
||||||
|
"no_friends_yet": "You haven't added any friends yet",
|
||||||
|
"view_all": "View all",
|
||||||
|
"load_more": "Load more",
|
||||||
|
"loading": "Loading",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"no_pending_invites": "You have no pending invites",
|
"no_pending_invites": "You have no pending invites",
|
||||||
"no_blocked_users": "You have no blocked users",
|
"no_blocked_users": "You have no blocked users",
|
||||||
@@ -699,8 +740,16 @@
|
|||||||
"report_reason_other": "Other",
|
"report_reason_other": "Other",
|
||||||
"profile_reported": "Profile reported",
|
"profile_reported": "Profile reported",
|
||||||
"your_friend_code": "Your friend code:",
|
"your_friend_code": "Your friend code:",
|
||||||
|
"copy_friend_code": "Copy friend code",
|
||||||
|
"copied": "Copied!",
|
||||||
"upload_banner": "Upload banner",
|
"upload_banner": "Upload banner",
|
||||||
"uploading_banner": "Uploading banner…",
|
"uploading_banner": "Uploading banner…",
|
||||||
|
"change_banner": "Change banner",
|
||||||
|
"replace_banner": "Replace banner",
|
||||||
|
"remove_banner": "Remove banner",
|
||||||
|
"remove_banner_modal_title": "Remove banner?",
|
||||||
|
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
|
||||||
|
"remove": "Remove",
|
||||||
"background_image_updated": "Background image updated",
|
"background_image_updated": "Background image updated",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"achievements": "achievements",
|
"achievements": "achievements",
|
||||||
@@ -718,10 +767,10 @@
|
|||||||
"game_added_to_pinned": "Game added to pinned",
|
"game_added_to_pinned": "Game added to pinned",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "Earned from positive likes on reviews",
|
|
||||||
"user_reviews": "Reviews",
|
"user_reviews": "Reviews",
|
||||||
"delete_review": "Delete Review",
|
"delete_review": "Delete Review",
|
||||||
"loading_reviews": "Loading reviews..."
|
"loading_reviews": "Loading reviews...",
|
||||||
|
"wrapped_2025": "Wrapped 2025"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
@@ -772,5 +821,41 @@
|
|||||||
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
||||||
"learn_more": "Learn More",
|
"learn_more": "Learn More",
|
||||||
"debrid_description": "Download up to 4x faster with Nimbus"
|
"debrid_description": "Download up to 4x faster with Nimbus"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"mark_all_as_read": "Mark all as read",
|
||||||
|
"clear_all": "Clear All",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"empty_title": "No notifications",
|
||||||
|
"empty_description": "You're all caught up! Check back later for new updates.",
|
||||||
|
"empty_filter_description": "No notifications match this filter.",
|
||||||
|
"filter_all": "All",
|
||||||
|
"filter_unread": "Unread",
|
||||||
|
"filter_friends": "Friends",
|
||||||
|
"filter_badges": "Badges",
|
||||||
|
"filter_upvotes": "Upvotes",
|
||||||
|
"filter_local": "Local",
|
||||||
|
"load_more": "Load more",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"accept": "Accept",
|
||||||
|
"refuse": "Refuse",
|
||||||
|
"notification": "Notification",
|
||||||
|
"friend_request_received_title": "New friend request!",
|
||||||
|
"friend_request_received_description": "{{displayName}} wants to be your friend",
|
||||||
|
"friend_request_accepted_title": "Friend request accepted!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
|
||||||
|
"badge_received_title": "You got a new badge!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
|
||||||
|
"review_upvote_description": "Your review received {{count}} new upvotes",
|
||||||
|
"marked_all_as_read": "All notifications marked as read",
|
||||||
|
"failed_to_mark_as_read": "Failed to mark notifications as read",
|
||||||
|
"cleared_all": "All notifications cleared",
|
||||||
|
"failed_to_clear": "Failed to clear notifications",
|
||||||
|
"failed_to_load": "Failed to load notifications",
|
||||||
|
"failed_to_dismiss": "Failed to dismiss notification",
|
||||||
|
"friend_request_accepted": "Friend request accepted",
|
||||||
|
"friend_request_refused": "Friend request refused"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
|
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
|
||||||
"sign_in": "Iniciar Sesión",
|
"sign_in": "Iniciar Sesión",
|
||||||
"friends": "Amigos",
|
"friends": "Amigos",
|
||||||
|
"notifications": "Notificaciones",
|
||||||
"need_help": "¿Necesitás ayuda?",
|
"need_help": "¿Necesitás ayuda?",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
"playable_button_title": "Solo mostrar juegos que podés jugar en este momento",
|
"playable_button_title": "Solo mostrar juegos que podés jugar en este momento",
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
|
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
|
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
|
||||||
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
|
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
|
||||||
|
"extracting": "Extrayendo {{title}}… ({{percentage}} completado)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Instalación completada",
|
"installation_complete": "Instalación completada",
|
||||||
"installation_complete_message": "Common redistributables instalados correctamente"
|
"installation_complete_message": "Common redistributables instalados correctamente"
|
||||||
@@ -173,6 +175,7 @@
|
|||||||
"repacks_modal_description": "Elegí el repack que querés descargar",
|
"repacks_modal_description": "Elegí el repack que querés descargar",
|
||||||
"select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
|
"select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
|
||||||
"download_now": "Descargar ahora",
|
"download_now": "Descargar ahora",
|
||||||
|
"loading": "Cargando...",
|
||||||
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
|
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
|
||||||
"download_options": "Opciones de descarga",
|
"download_options": "Opciones de descarga",
|
||||||
"download_path": "Ruta de descarga",
|
"download_path": "Ruta de descarga",
|
||||||
@@ -182,6 +185,12 @@
|
|||||||
"open_screenshot": "Abrir captura número {{number}}",
|
"open_screenshot": "Abrir captura número {{number}}",
|
||||||
"download_settings": "Descargar ajustes",
|
"download_settings": "Descargar ajustes",
|
||||||
"downloader": "Descargador",
|
"downloader": "Descargador",
|
||||||
|
"downloader_online": "En línea",
|
||||||
|
"downloader_not_configured": "Disponible pero no configurado",
|
||||||
|
"downloader_offline": "El enlace está fuera de línea",
|
||||||
|
"downloader_not_available": "No disponible",
|
||||||
|
"recommended": "Recomendado",
|
||||||
|
"go_to_settings": "Ir a Ajustes",
|
||||||
"select_executable": "Seleccionar",
|
"select_executable": "Seleccionar",
|
||||||
"no_executable_selected": "Sin ejecutable seleccionado",
|
"no_executable_selected": "Sin ejecutable seleccionado",
|
||||||
"open_folder": "Abrir carpeta",
|
"open_folder": "Abrir carpeta",
|
||||||
@@ -200,6 +209,7 @@
|
|||||||
"danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
|
"danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
|
||||||
"download_in_progress": "Descarga en progreso",
|
"download_in_progress": "Descarga en progreso",
|
||||||
"download_paused": "Descarga pausada",
|
"download_paused": "Descarga pausada",
|
||||||
|
"extracting": "Extrayendo",
|
||||||
"last_downloaded_option": "Última opción de descarga",
|
"last_downloaded_option": "Última opción de descarga",
|
||||||
"new_download_option": "Nuevo",
|
"new_download_option": "Nuevo",
|
||||||
"create_steam_shortcut": "Crear atajo de Steam",
|
"create_steam_shortcut": "Crear atajo de Steam",
|
||||||
@@ -394,6 +404,10 @@
|
|||||||
"completed": "Completado",
|
"completed": "Completado",
|
||||||
"removed": "No descargado",
|
"removed": "No descargado",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"cancel_download": "¿Cancelar descarga?",
|
||||||
|
"cancel_download_description": "¿Estás seguro de que querés cancelar esta descarga? Todos los archivos descargados serán eliminados.",
|
||||||
|
"keep_downloading": "No, seguir descargando",
|
||||||
|
"yes_cancel": "Sí, cancelar",
|
||||||
"filter": "Filtrar juegos descargados",
|
"filter": "Filtrar juegos descargados",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
"downloading_metadata": "Descargando metadatos…",
|
"downloading_metadata": "Descargando metadatos…",
|
||||||
@@ -414,7 +428,13 @@
|
|||||||
"resume_seeding": "Continuar sembrando",
|
"resume_seeding": "Continuar sembrando",
|
||||||
"options": "Administrar",
|
"options": "Administrar",
|
||||||
"extract": "Extraer archivos",
|
"extract": "Extraer archivos",
|
||||||
"extracting": "Extrayendo archivos…"
|
"extracting": "Extrayendo archivos…",
|
||||||
|
"delete_archive_title": "¿Querés eliminar {{fileName}}?",
|
||||||
|
"delete_archive_description": "El archivo se extrajo exitosamente y ya no es necesario.",
|
||||||
|
"yes": "Sí",
|
||||||
|
"no": "No",
|
||||||
|
"network": "RED",
|
||||||
|
"peak": "PICO"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Ruta de descarga",
|
"downloads_path": "Ruta de descarga",
|
||||||
@@ -538,6 +558,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
|
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
|
||||||
"extract_files_by_default": "Extraer archivos por defecto después de descargar",
|
"extract_files_by_default": "Extraer archivos por defecto después de descargar",
|
||||||
"enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
|
"enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
|
||||||
|
"enable_new_download_options_badges": "Mostrar badges de nuevas opciones de descarga",
|
||||||
"achievement_custom_notification_position": "Posición de notificación de logros",
|
"achievement_custom_notification_position": "Posición de notificación de logros",
|
||||||
"top-left": "Superior Izquierda",
|
"top-left": "Superior Izquierda",
|
||||||
"top-center": "Superior Centro",
|
"top-center": "Superior Centro",
|
||||||
@@ -564,20 +585,10 @@
|
|||||||
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
|
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
|
||||||
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
|
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
|
||||||
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
|
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
|
||||||
"change_achievement_sound": "Cambiar sonido de logro",
|
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
|
||||||
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
|
"downloads": "Descargas",
|
||||||
"download_source_failed": "Error",
|
"use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
|
||||||
"download_source_matched": "Actualizado",
|
"cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso"
|
||||||
"download_source_matching": "Actualizando",
|
|
||||||
"download_source_no_information": "Sin información disponible",
|
|
||||||
"download_source_pending_matching": "Actualizando pronto",
|
|
||||||
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
|
|
||||||
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
|
|
||||||
"hydra_cloud": "Hydra Cloud",
|
|
||||||
"preview_sound": "Vista previa de sonido",
|
|
||||||
"remove_achievement_sound": "Eliminar sonido de logros",
|
|
||||||
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
|
|
||||||
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Descarga completada",
|
"download_complete": "Descarga completada",
|
||||||
@@ -651,6 +662,7 @@
|
|||||||
"sending": "Enviando",
|
"sending": "Enviando",
|
||||||
"friend_request_sent": "Solicitud de amistad enviada",
|
"friend_request_sent": "Solicitud de amistad enviada",
|
||||||
"friends": "Amistades",
|
"friends": "Amistades",
|
||||||
|
"badges": "Insignias",
|
||||||
"friends_list": "Lista de amistades",
|
"friends_list": "Lista de amistades",
|
||||||
"user_not_found": "Usuario no encontrado",
|
"user_not_found": "Usuario no encontrado",
|
||||||
"block_user": "Bloquear usuario",
|
"block_user": "Bloquear usuario",
|
||||||
@@ -661,12 +673,17 @@
|
|||||||
"ignore_request": "Ignorar solicitud",
|
"ignore_request": "Ignorar solicitud",
|
||||||
"cancel_request": "Cancelar solicitud",
|
"cancel_request": "Cancelar solicitud",
|
||||||
"undo_friendship": "Deshacer amistad",
|
"undo_friendship": "Deshacer amistad",
|
||||||
|
"friendship_removed": "Amigo eliminado",
|
||||||
"request_accepted": "Solicitud aceptada",
|
"request_accepted": "Solicitud aceptada",
|
||||||
"user_blocked_successfully": "Usuario bloqueado exitosamente",
|
"user_blocked_successfully": "Usuario bloqueado exitosamente",
|
||||||
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
|
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
|
||||||
"blocked_users": "Usuarios bloqueados",
|
"blocked_users": "Usuarios bloqueados",
|
||||||
"unblock": "Desbloquear",
|
"unblock": "Desbloquear",
|
||||||
"no_friends_added": "No tenés amistades añadidas",
|
"no_friends_added": "No tenés amistades añadidas",
|
||||||
|
"no_friends_yet": "Aún no has agregado ningún amigo",
|
||||||
|
"view_all": "Ver todo",
|
||||||
|
"load_more": "Cargar más",
|
||||||
|
"loading": "Cargando",
|
||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"no_pending_invites": "No tenés invitaciones pendientes",
|
"no_pending_invites": "No tenés invitaciones pendientes",
|
||||||
"no_blocked_users": "No has bloqueado a nadie",
|
"no_blocked_users": "No has bloqueado a nadie",
|
||||||
@@ -690,8 +707,16 @@
|
|||||||
"report_reason_other": "Otros",
|
"report_reason_other": "Otros",
|
||||||
"profile_reported": "Perfil reportado",
|
"profile_reported": "Perfil reportado",
|
||||||
"your_friend_code": "Tu código de amistad:",
|
"your_friend_code": "Tu código de amistad:",
|
||||||
|
"copy_friend_code": "Copiar código de amistad",
|
||||||
|
"copied": "¡Copiado!",
|
||||||
"upload_banner": "Subir banner",
|
"upload_banner": "Subir banner",
|
||||||
"uploading_banner": "Subiendo banner…",
|
"uploading_banner": "Subiendo banner…",
|
||||||
|
"change_banner": "Cambiar banner",
|
||||||
|
"replace_banner": "Reemplazar banner",
|
||||||
|
"remove_banner": "Eliminar banner",
|
||||||
|
"remove_banner_modal_title": "¿Eliminar banner?",
|
||||||
|
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
|
||||||
|
"remove": "Eliminar",
|
||||||
"background_image_updated": "Imagen de fondo actualizada",
|
"background_image_updated": "Imagen de fondo actualizada",
|
||||||
"stats": "Estadísticas",
|
"stats": "Estadísticas",
|
||||||
"achievements": "logros",
|
"achievements": "logros",
|
||||||
@@ -710,11 +735,11 @@
|
|||||||
"amount_minutes_short": "{{amount}}m",
|
"amount_minutes_short": "{{amount}}m",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "Conseguido por me gustas positivos en reseñas",
|
|
||||||
"sort_by": "Filtrar por:",
|
"sort_by": "Filtrar por:",
|
||||||
"game_added_to_pinned": "Juego añadido a fijados",
|
"game_added_to_pinned": "Juego añadido a fijados",
|
||||||
"user_reviews": "Reseñas",
|
"user_reviews": "Reseñas",
|
||||||
"loading_reviews": "Cargando reseñas...",
|
"loading_reviews": "Cargando reseñas...",
|
||||||
|
"wrapped_2025": "Wrapped 2025",
|
||||||
"no_reviews": "Sin reseñas aún",
|
"no_reviews": "Sin reseñas aún",
|
||||||
"delete_review": "Eliminar reseña"
|
"delete_review": "Eliminar reseña"
|
||||||
},
|
},
|
||||||
@@ -767,5 +792,41 @@
|
|||||||
"all_games": "Todos los Juegos",
|
"all_games": "Todos los Juegos",
|
||||||
"recently_played": "Jugados Recientemente",
|
"recently_played": "Jugados Recientemente",
|
||||||
"favorites": "Favoritos"
|
"favorites": "Favoritos"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Notificaciones",
|
||||||
|
"mark_all_as_read": "Marcar todo como leído",
|
||||||
|
"clear_all": "Limpiar todo",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"empty_title": "Sin notificaciones",
|
||||||
|
"empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.",
|
||||||
|
"empty_filter_description": "No hay notificaciones que coincidan con este filtro.",
|
||||||
|
"filter_all": "Todas",
|
||||||
|
"filter_unread": "No leídas",
|
||||||
|
"filter_friends": "Amigos",
|
||||||
|
"filter_badges": "Insignias",
|
||||||
|
"filter_upvotes": "Votos",
|
||||||
|
"filter_local": "Locales",
|
||||||
|
"load_more": "Cargar más",
|
||||||
|
"dismiss": "Descartar",
|
||||||
|
"accept": "Aceptar",
|
||||||
|
"refuse": "Rechazar",
|
||||||
|
"notification": "Notificación",
|
||||||
|
"friend_request_received_title": "¡Nueva solicitud de amistad!",
|
||||||
|
"friend_request_received_description": "{{displayName}} quiere ser tu amigo",
|
||||||
|
"friend_request_accepted_title": "¡Solicitud de amistad aceptada!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad",
|
||||||
|
"badge_received_title": "¡Obtuviste una nueva insignia!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!",
|
||||||
|
"review_upvote_description": "Tu reseña recibió {{count}} nuevos votos",
|
||||||
|
"marked_all_as_read": "Todas las notificaciones marcadas como leídas",
|
||||||
|
"failed_to_mark_as_read": "Error al marcar las notificaciones como leídas",
|
||||||
|
"cleared_all": "Todas las notificaciones eliminadas",
|
||||||
|
"failed_to_clear": "Error al eliminar las notificaciones",
|
||||||
|
"failed_to_load": "Error al cargar las notificaciones",
|
||||||
|
"failed_to_dismiss": "Error al descartar la notificación",
|
||||||
|
"friend_request_accepted": "Solicitud de amistad aceptada",
|
||||||
|
"friend_request_refused": "Solicitud de amistad rechazada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -673,8 +673,7 @@
|
|||||||
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
|
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
|
||||||
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
|
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karmaa",
|
"karma_count": "karmaa"
|
||||||
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
|
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Saavutus avattu",
|
"achievement_unlocked": "Saavutus avattu",
|
||||||
|
|||||||
@@ -22,10 +22,11 @@
|
|||||||
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
||||||
"filter": "Könyvtár szűrése",
|
"filter": "Könyvtár szűrése",
|
||||||
"home": "Főoldal",
|
"home": "Főoldal",
|
||||||
"queued": "A(z) {{title}} (Várakozósorban van)",
|
"queued": "{{title}} (Várakozásban)",
|
||||||
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
||||||
"sign_in": "Bejelentkezés",
|
"sign_in": "Bejelentkezés",
|
||||||
"friends": "Barátok",
|
"friends": "Barátok",
|
||||||
|
"notifications": "Értesítések",
|
||||||
"need_help": "Elakadtál?",
|
"need_help": "Elakadtál?",
|
||||||
"favorites": "Kedvenc Játékaim",
|
"favorites": "Kedvenc Játékaim",
|
||||||
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
|
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
|
||||||
@@ -94,6 +95,12 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"search": "Keresés",
|
"search": "Keresés",
|
||||||
"search_library": "Könyvtár böngészése",
|
"search_library": "Könyvtár böngészése",
|
||||||
|
"recent_searches": "Korábbi Keresések",
|
||||||
|
"suggestions": "Találatok",
|
||||||
|
"clear_history": "Törlés",
|
||||||
|
"remove_from_history": "Törlés az előzményekből",
|
||||||
|
"loading": "Töltés...",
|
||||||
|
"no_results": "Nincs találat",
|
||||||
"home": "Főoldal",
|
"home": "Főoldal",
|
||||||
"catalogue": "Katalógus",
|
"catalogue": "Katalógus",
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
@@ -109,6 +116,7 @@
|
|||||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
||||||
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
||||||
|
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Telepítés befejezve",
|
"installation_complete": "Telepítés befejezve",
|
||||||
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
||||||
@@ -165,8 +173,9 @@
|
|||||||
"playing_now": "Játékban: ",
|
"playing_now": "Játékban: ",
|
||||||
"change": "Változtatás",
|
"change": "Változtatás",
|
||||||
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
||||||
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
|
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
|
||||||
"download_now": "Letöltés",
|
"download_now": "Letöltés",
|
||||||
|
"loading": "Töltés...",
|
||||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||||
"download_options": "Letöltési opciók",
|
"download_options": "Letöltési opciók",
|
||||||
"download_path": "Letöltési hely",
|
"download_path": "Letöltési hely",
|
||||||
@@ -175,7 +184,13 @@
|
|||||||
"screenshot": "Screenshot {{number}}",
|
"screenshot": "Screenshot {{number}}",
|
||||||
"open_screenshot": "{{number}} Screenshot megnyitása ",
|
"open_screenshot": "{{number}} Screenshot megnyitása ",
|
||||||
"download_settings": "Letöltési beállítások",
|
"download_settings": "Letöltési beállítások",
|
||||||
"downloader": "Letöltési mód",
|
"downloader": "Letöltő",
|
||||||
|
"downloader_online": "Elérhető",
|
||||||
|
"downloader_not_configured": "Elérhető de nincs beállítva",
|
||||||
|
"downloader_offline": "A link nem elérhető",
|
||||||
|
"downloader_not_available": "Nem elérhető",
|
||||||
|
"recommended": "Ajánlott",
|
||||||
|
"go_to_settings": "Beállítások megnyitása",
|
||||||
"select_executable": "Tallózás",
|
"select_executable": "Tallózás",
|
||||||
"no_executable_selected": "Nincs futtatható fájl tallózva",
|
"no_executable_selected": "Nincs futtatható fájl tallózva",
|
||||||
"open_folder": "Mappa megnyitása",
|
"open_folder": "Mappa megnyitása",
|
||||||
@@ -196,6 +211,7 @@
|
|||||||
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
||||||
"download_in_progress": "Letöltés folyamatban",
|
"download_in_progress": "Letöltés folyamatban",
|
||||||
"download_paused": "Letöltés szüneteltetve",
|
"download_paused": "Letöltés szüneteltetve",
|
||||||
|
"extracting": "Kicsomagolás",
|
||||||
"last_downloaded_option": "Utoljára letöltött",
|
"last_downloaded_option": "Utoljára letöltött",
|
||||||
"new_download_option": "Új",
|
"new_download_option": "Új",
|
||||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||||
@@ -397,7 +413,7 @@
|
|||||||
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
||||||
"install": "Telepít",
|
"install": "Telepít",
|
||||||
"download_in_progress": "Folyamatban lévő",
|
"download_in_progress": "Folyamatban lévő",
|
||||||
"queued_downloads": "Várakozósoron lévő letöltések",
|
"queued_downloads": "Várakozásban lévő letöltések",
|
||||||
"downloads_completed": "Befejezett",
|
"downloads_completed": "Befejezett",
|
||||||
"queued": "Várakozásban",
|
"queued": "Várakozásban",
|
||||||
"no_downloads_title": "Oly üres..",
|
"no_downloads_title": "Oly üres..",
|
||||||
@@ -408,7 +424,13 @@
|
|||||||
"resume_seeding": "Seedelés folytatása",
|
"resume_seeding": "Seedelés folytatása",
|
||||||
"options": "Kezelés",
|
"options": "Kezelés",
|
||||||
"extract": "Fájlok kibontása",
|
"extract": "Fájlok kibontása",
|
||||||
"extracting": "Fájlok kibontása…"
|
"extracting": "Fájlok kibontása…",
|
||||||
|
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
|
||||||
|
"delete_archive_description": "A tömörített fájl ki lett csomagolva és többé nincs rá szükség.",
|
||||||
|
"yes": "Igen",
|
||||||
|
"no": "Nem",
|
||||||
|
"network": "HÁLÓZAT",
|
||||||
|
"peak": "CSÚCS"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Letöltési útvonalak",
|
"downloads_path": "Letöltési útvonalak",
|
||||||
@@ -432,7 +454,7 @@
|
|||||||
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
|
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
|
||||||
"save_changes": "Változtatások mentése",
|
"save_changes": "Változtatások mentése",
|
||||||
"changes_saved": "Változtatások sikeresen mentve",
|
"changes_saved": "Változtatások sikeresen mentve",
|
||||||
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
|
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból, ennek az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
|
||||||
"validate_download_source": "Érvényesítés",
|
"validate_download_source": "Érvényesítés",
|
||||||
"remove_download_source": "Eltávolítás",
|
"remove_download_source": "Eltávolítás",
|
||||||
"add_download_source": "Forrás hozáadása",
|
"add_download_source": "Forrás hozáadása",
|
||||||
@@ -544,6 +566,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
|
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
|
||||||
"extract_files_by_default": "Fájlok kicsomagolása letöltés után",
|
"extract_files_by_default": "Fájlok kicsomagolása letöltés után",
|
||||||
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
|
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
|
||||||
|
"enable_new_download_options_badges": "Új letöltési helyek",
|
||||||
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
|
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
|
||||||
"top-left": "Bal felső sarok",
|
"top-left": "Bal felső sarok",
|
||||||
"top-center": "Felső közép",
|
"top-center": "Felső közép",
|
||||||
@@ -624,9 +647,9 @@
|
|||||||
"sort_by": "Rendezés:",
|
"sort_by": "Rendezés:",
|
||||||
"achievements_earned": "Elért achievementek",
|
"achievements_earned": "Elért achievementek",
|
||||||
"played_recently": "Nemrég játszva",
|
"played_recently": "Nemrég játszva",
|
||||||
"playtime": "Játszottidő",
|
"playtime": "Játékidő",
|
||||||
"total_play_time": "Teljes játszottidő",
|
"total_play_time": "Teljes játékidő",
|
||||||
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
|
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
|
||||||
"no_recent_activity_title": "Hmmm… itt semmi sincs",
|
"no_recent_activity_title": "Hmmm… itt semmi sincs",
|
||||||
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
|
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
|
||||||
"display_name": "Profilnév",
|
"display_name": "Profilnév",
|
||||||
@@ -648,6 +671,7 @@
|
|||||||
"sending": "Küldés..",
|
"sending": "Küldés..",
|
||||||
"friend_request_sent": "Barátfelkérés elküldve",
|
"friend_request_sent": "Barátfelkérés elküldve",
|
||||||
"friends": "Barátok",
|
"friends": "Barátok",
|
||||||
|
"badges": "Kitűzők",
|
||||||
"friends_list": "Barát lista",
|
"friends_list": "Barát lista",
|
||||||
"user_not_found": "Felhasználó nem találva",
|
"user_not_found": "Felhasználó nem találva",
|
||||||
"block_user": "Felhasználó letiltása",
|
"block_user": "Felhasználó letiltása",
|
||||||
@@ -658,18 +682,22 @@
|
|||||||
"ignore_request": "Kérés ignorálása",
|
"ignore_request": "Kérés ignorálása",
|
||||||
"cancel_request": "Kérés visszavonása",
|
"cancel_request": "Kérés visszavonása",
|
||||||
"undo_friendship": "Barát eltávolítása",
|
"undo_friendship": "Barát eltávolítása",
|
||||||
|
"friendship_removed": "Barát eltávolítva",
|
||||||
"request_accepted": "Barátfelkérés elfogadva",
|
"request_accepted": "Barátfelkérés elfogadva",
|
||||||
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
|
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
|
||||||
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
|
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
|
||||||
"blocked_users": "Letiltott felhasználók",
|
"blocked_users": "Letiltott felhasználók",
|
||||||
"unblock": "Tiltás feloldása",
|
"unblock": "Tiltás feloldása",
|
||||||
"no_friends_added": "Nincs bejelölt barátod",
|
"no_friends_added": "Nincs bejelölt barátod",
|
||||||
|
"view_all": "Összes megtekintése",
|
||||||
|
"load_more": "Több betöltése",
|
||||||
|
"loading": "Töltés..",
|
||||||
"pending": "Függőben",
|
"pending": "Függőben",
|
||||||
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
|
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
|
||||||
"no_blocked_users": "Nincs letiltott felhasználó",
|
"no_blocked_users": "Nincs letiltott felhasználó",
|
||||||
"friend_code_copied": "Barát kód kimásolva",
|
"friend_code_copied": "Barát kód kimásolva",
|
||||||
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
||||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
|
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
|
||||||
"locked_profile": "Ez a profil privát",
|
"locked_profile": "Ez a profil privát",
|
||||||
"image_process_failure": "Hiba a kép feldolgozása közben",
|
"image_process_failure": "Hiba a kép feldolgozása közben",
|
||||||
"required_field": "Ez a mező kötelező",
|
"required_field": "Ez a mező kötelező",
|
||||||
@@ -687,6 +715,7 @@
|
|||||||
"report_reason_other": "Egyéb",
|
"report_reason_other": "Egyéb",
|
||||||
"profile_reported": "Profil bejelentve",
|
"profile_reported": "Profil bejelentve",
|
||||||
"your_friend_code": "A barát kódod:",
|
"your_friend_code": "A barát kódod:",
|
||||||
|
"copy_friend_code": "Barátkód kimásolása",
|
||||||
"upload_banner": "Borítókép feltöltése",
|
"upload_banner": "Borítókép feltöltése",
|
||||||
"uploading_banner": "Borítókép feltöltése…",
|
"uploading_banner": "Borítókép feltöltése…",
|
||||||
"background_image_updated": "Borítókép frissítve",
|
"background_image_updated": "Borítókép frissítve",
|
||||||
@@ -706,10 +735,12 @@
|
|||||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "Pozitív értékelésekkel szerzett pontok",
|
|
||||||
"user_reviews": "Vélemények",
|
"user_reviews": "Vélemények",
|
||||||
"delete_review": "Vélemény Törlése",
|
"delete_review": "Vélemény Törlése",
|
||||||
"loading_reviews": "Vélemények betöltése..."
|
"loading_reviews": "Vélemények betöltése...",
|
||||||
|
"wrapped_2025": "Wrapped 2025",
|
||||||
|
"view_my_wrapped_button": "Wrapped 2025 megtekintése",
|
||||||
|
"view_wrapped_button": "{{displayName}} Wrapped 2025 megtekintése"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
@@ -727,7 +758,7 @@
|
|||||||
"amount_minutes": "{{amount}} perc",
|
"amount_minutes": "{{amount}} perc",
|
||||||
"amount_hours_short": "{{amount}}ó",
|
"amount_hours_short": "{{amount}}ó",
|
||||||
"amount_minutes_short": "{{amount}}p",
|
"amount_minutes_short": "{{amount}}p",
|
||||||
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
|
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
|
||||||
"all_games": "Összes Játék",
|
"all_games": "Összes Játék",
|
||||||
"recently_played": "Nemrég Játszva",
|
"recently_played": "Nemrég Játszva",
|
||||||
"favorites": "Kedvencek"
|
"favorites": "Kedvencek"
|
||||||
@@ -760,5 +791,41 @@
|
|||||||
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
|
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
|
||||||
"learn_more": "Tudj meg többet",
|
"learn_more": "Tudj meg többet",
|
||||||
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
|
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Értesítések",
|
||||||
|
"mark_all_as_read": "Megjelölés olvasottként",
|
||||||
|
"clear_all": "Összes Törlése",
|
||||||
|
"loading": "Töltés..",
|
||||||
|
"empty_title": "Nincsenek értesítések",
|
||||||
|
"empty_description": "Már mindet láttad! Nézz vissza később az újdonságokért.",
|
||||||
|
"empty_filter_description": "Nincs értesítés ami megfelel ennek a szűrőnek.",
|
||||||
|
"filter_all": "Összes",
|
||||||
|
"filter_unread": "Olvasatlan",
|
||||||
|
"filter_friends": "Barátok",
|
||||||
|
"filter_badges": "Kitűzők",
|
||||||
|
"filter_upvotes": "Felpontok",
|
||||||
|
"filter_local": "Helyi",
|
||||||
|
"load_more": "Több betöltése",
|
||||||
|
"dismiss": "Eltüntetés",
|
||||||
|
"accept": "Elfogad",
|
||||||
|
"refuse": "Elutasít",
|
||||||
|
"notification": "Értesítés",
|
||||||
|
"friend_request_received_title": "Új barátkérelem!",
|
||||||
|
"friend_request_received_description": "{{displayName}} a barátod szeretne lenni",
|
||||||
|
"friend_request_accepted_title": "Barátkérelem elfogadva!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} elfogadta a barátkérelmed",
|
||||||
|
"badge_received_title": "Kaptál egy új kitűzőt!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "A véleményed a(z) {{gameTitle}} játékhoz felpont-ot kapott!",
|
||||||
|
"review_upvote_description": "A véleményed {{count}} új felpontot kapott",
|
||||||
|
"marked_all_as_read": "Összes értesítés olvasottnak jelölve",
|
||||||
|
"failed_to_mark_as_read": "Az értesítések olvasottnak jelölése nem sikerült",
|
||||||
|
"cleared_all": "Összes értesítés eltüntetve",
|
||||||
|
"failed_to_clear": "Az értesítések eltüntetése nem sikerült",
|
||||||
|
"failed_to_load": "Az értesítések betöltése nem sikerült",
|
||||||
|
"failed_to_dismiss": "Értesítés eltüntetése nem sikerült",
|
||||||
|
"friend_request_accepted": "Barátfelkérés elfogadva",
|
||||||
|
"friend_request_refused": "Barátfelkérés elutasítva"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -673,8 +673,7 @@
|
|||||||
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
|
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
|
||||||
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
|
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma"
|
||||||
"karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
|
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Sasniegums atbloķēts",
|
"achievement_unlocked": "Sasniegums atbloķēts",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"game_has_no_executable": "Jogo não possui executável selecionado",
|
"game_has_no_executable": "Jogo não possui executável selecionado",
|
||||||
"sign_in": "Login",
|
"sign_in": "Login",
|
||||||
"friends": "Amigos",
|
"friends": "Amigos",
|
||||||
|
"notifications": "Notificações",
|
||||||
"need_help": "Precisa de ajuda?",
|
"need_help": "Precisa de ajuda?",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
|
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
|
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
|
||||||
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
|
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
|
||||||
"download_now": "Iniciar download",
|
"download_now": "Iniciar download",
|
||||||
|
"loading": "Carregando...",
|
||||||
"no_shop_details": "Não foi possível obter os detalhes da loja.",
|
"no_shop_details": "Não foi possível obter os detalhes da loja.",
|
||||||
"download_options": "Opções de download",
|
"download_options": "Opções de download",
|
||||||
"download_path": "Diretório de download",
|
"download_path": "Diretório de download",
|
||||||
@@ -172,6 +174,12 @@
|
|||||||
"open_screenshot": "Ver captura de tela {{number}}",
|
"open_screenshot": "Ver captura de tela {{number}}",
|
||||||
"download_settings": "Ajustes do download",
|
"download_settings": "Ajustes do download",
|
||||||
"downloader": "Downloader",
|
"downloader": "Downloader",
|
||||||
|
"downloader_online": "Online",
|
||||||
|
"downloader_not_configured": "Disponível mas não configurado",
|
||||||
|
"downloader_offline": "Link está offline",
|
||||||
|
"downloader_not_available": "Não disponível",
|
||||||
|
"recommended": "Recomendado",
|
||||||
|
"go_to_settings": "Ir para Configurações",
|
||||||
"select_executable": "Explorar",
|
"select_executable": "Explorar",
|
||||||
"no_executable_selected": "Nenhum executável selecionado",
|
"no_executable_selected": "Nenhum executável selecionado",
|
||||||
"open_folder": "Abrir pasta",
|
"open_folder": "Abrir pasta",
|
||||||
@@ -362,6 +370,7 @@
|
|||||||
"show_translation": "Mostrar tradução",
|
"show_translation": "Mostrar tradução",
|
||||||
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
|
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
|
||||||
"hide_original": "Ocultar original",
|
"hide_original": "Ocultar original",
|
||||||
|
"vote_failed": "Falha ao registrar seu voto. Por favor, tente novamente.",
|
||||||
"rating_count": "Avaliação",
|
"rating_count": "Avaliação",
|
||||||
"review_from_blocked_user": "Avaliação de usuário bloqueado",
|
"review_from_blocked_user": "Avaliação de usuário bloqueado",
|
||||||
"show": "Mostrar",
|
"show": "Mostrar",
|
||||||
@@ -384,6 +393,10 @@
|
|||||||
"completed": "Concluído",
|
"completed": "Concluído",
|
||||||
"removed": "Cancelado",
|
"removed": "Cancelado",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
"cancel_download": "Cancelar download?",
|
||||||
|
"cancel_download_description": "Tem certeza de que deseja cancelar este download? Todos os arquivos baixados serão excluídos.",
|
||||||
|
"keep_downloading": "Não, continuar baixando",
|
||||||
|
"yes_cancel": "Sim, cancelar",
|
||||||
"filter": "Filtrar jogos baixados",
|
"filter": "Filtrar jogos baixados",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
"downloading_metadata": "Baixando metadados…",
|
"downloading_metadata": "Baixando metadados…",
|
||||||
@@ -408,7 +421,9 @@
|
|||||||
"delete_archive_title": "Deseja deletar {{fileName}}?",
|
"delete_archive_title": "Deseja deletar {{fileName}}?",
|
||||||
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
|
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
|
||||||
"yes": "Sim",
|
"yes": "Sim",
|
||||||
"no": "Não"
|
"no": "Não",
|
||||||
|
"network": "REDE",
|
||||||
|
"peak": "PICO"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Diretório dos downloads",
|
"downloads_path": "Diretório dos downloads",
|
||||||
@@ -455,6 +470,7 @@
|
|||||||
"download_sources_synced_successfully": "Fontes de download sincronizadas",
|
"download_sources_synced_successfully": "Fontes de download sincronizadas",
|
||||||
"removed_download_source": "Fonte removida",
|
"removed_download_source": "Fonte removida",
|
||||||
"removed_download_sources": "Fontes removidas",
|
"removed_download_sources": "Fontes removidas",
|
||||||
|
"removed_all_download_sources": "Todas as fontes de download removidas",
|
||||||
"cancel_button_confirmation_delete_all_sources": "Não",
|
"cancel_button_confirmation_delete_all_sources": "Não",
|
||||||
"confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo",
|
"confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo",
|
||||||
"title_confirmation_delete_all_sources": "Remover todas as fontes de download",
|
"title_confirmation_delete_all_sources": "Remover todas as fontes de download",
|
||||||
@@ -480,6 +496,7 @@
|
|||||||
"blocked_users": "Usuários bloqueados",
|
"blocked_users": "Usuários bloqueados",
|
||||||
"user_unblocked": "Usuário desbloqueado",
|
"user_unblocked": "Usuário desbloqueado",
|
||||||
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
|
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
|
||||||
|
"hydra_cloud": "Hydra Cloud",
|
||||||
"launch_minimized": "Iniciar o Hydra minimizado",
|
"launch_minimized": "Iniciar o Hydra minimizado",
|
||||||
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
|
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
|
||||||
"seed_after_download_complete": "Semear após a conclusão do download",
|
"seed_after_download_complete": "Semear após a conclusão do download",
|
||||||
@@ -542,6 +559,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
|
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
|
||||||
"extract_files_by_default": "Extrair arquivos automaticamente após o download",
|
"extract_files_by_default": "Extrair arquivos automaticamente após o download",
|
||||||
"enable_steam_achievements": "Habilitar busca por conquistas da Steam",
|
"enable_steam_achievements": "Habilitar busca por conquistas da Steam",
|
||||||
|
"enable_new_download_options_badges": "Mostrar badges de novas opções de download",
|
||||||
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
|
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
|
||||||
"top-left": "Superior esquerdo",
|
"top-left": "Superior esquerdo",
|
||||||
"top-center": "Superior central",
|
"top-center": "Superior central",
|
||||||
@@ -559,6 +577,9 @@
|
|||||||
"test_notification": "Testar notificação",
|
"test_notification": "Testar notificação",
|
||||||
"achievement_sound_volume": "Volume do som de conquista",
|
"achievement_sound_volume": "Volume do som de conquista",
|
||||||
"select_achievement_sound": "Selecionar som de conquista",
|
"select_achievement_sound": "Selecionar som de conquista",
|
||||||
|
"change_achievement_sound": "Alterar som de conquista",
|
||||||
|
"remove_achievement_sound": "Remover som de conquista",
|
||||||
|
"preview_sound": "Reproduzir som",
|
||||||
"select": "Selecionar",
|
"select": "Selecionar",
|
||||||
"preview": "Reproduzir",
|
"preview": "Reproduzir",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
@@ -566,7 +587,10 @@
|
|||||||
"notification_preview": "Prévia da Notificação de Conquistas",
|
"notification_preview": "Prévia da Notificação de Conquistas",
|
||||||
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
|
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
|
||||||
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
|
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
|
||||||
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo"
|
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo",
|
||||||
|
"downloads": "Downloads",
|
||||||
|
"use_native_http_downloader": "Usar downloader HTTP nativo (experimental)",
|
||||||
|
"cannot_change_downloader_while_downloading": "Não é possível alterar esta configuração enquanto um download estiver em andamento"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download concluído",
|
"download_complete": "Download concluído",
|
||||||
@@ -652,6 +676,7 @@
|
|||||||
"see_profile": "Ver perfil",
|
"see_profile": "Ver perfil",
|
||||||
"friend_request_sent": "Pedido de amizade enviado",
|
"friend_request_sent": "Pedido de amizade enviado",
|
||||||
"friends": "Amigos",
|
"friends": "Amigos",
|
||||||
|
"badges": "Insígnias",
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"sending": "Enviando",
|
"sending": "Enviando",
|
||||||
"friends_list": "Lista de amigos",
|
"friends_list": "Lista de amigos",
|
||||||
@@ -664,12 +689,17 @@
|
|||||||
"ignore_request": "Ignorar pedido",
|
"ignore_request": "Ignorar pedido",
|
||||||
"cancel_request": "Cancelar pedido",
|
"cancel_request": "Cancelar pedido",
|
||||||
"undo_friendship": "Desfazer amizade",
|
"undo_friendship": "Desfazer amizade",
|
||||||
|
"friendship_removed": "Amigo removido",
|
||||||
"request_accepted": "Pedido de amizade aceito",
|
"request_accepted": "Pedido de amizade aceito",
|
||||||
"user_blocked_successfully": "Usuário bloqueado com sucesso",
|
"user_blocked_successfully": "Usuário bloqueado com sucesso",
|
||||||
"user_block_modal_text": "Bloquear {{displayName}}",
|
"user_block_modal_text": "Bloquear {{displayName}}",
|
||||||
"blocked_users": "Usuários bloqueados",
|
"blocked_users": "Usuários bloqueados",
|
||||||
"unblock": "Desbloquear",
|
"unblock": "Desbloquear",
|
||||||
"no_friends_added": "Você ainda não possui amigos adicionados",
|
"no_friends_added": "Você ainda não possui amigos adicionados",
|
||||||
|
"no_friends_yet": "Você ainda não adicionou nenhum amigo",
|
||||||
|
"view_all": "Ver todos",
|
||||||
|
"load_more": "Carregar mais",
|
||||||
|
"loading": "Carregando",
|
||||||
"pending": "Pendentes",
|
"pending": "Pendentes",
|
||||||
"no_pending_invites": "Você não possui convites de amizade pendentes",
|
"no_pending_invites": "Você não possui convites de amizade pendentes",
|
||||||
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
|
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
|
||||||
@@ -693,8 +723,16 @@
|
|||||||
"report_reason_other": "Outro",
|
"report_reason_other": "Outro",
|
||||||
"profile_reported": "Perfil reportado",
|
"profile_reported": "Perfil reportado",
|
||||||
"your_friend_code": "Seu código de amigo:",
|
"your_friend_code": "Seu código de amigo:",
|
||||||
|
"copy_friend_code": "Copiar código de amigo",
|
||||||
|
"copied": "Copiado!",
|
||||||
"upload_banner": "Carregar banner",
|
"upload_banner": "Carregar banner",
|
||||||
"uploading_banner": "Carregando banner…",
|
"uploading_banner": "Carregando banner…",
|
||||||
|
"change_banner": "Alterar banner",
|
||||||
|
"replace_banner": "Substituir banner",
|
||||||
|
"remove_banner": "Remover banner",
|
||||||
|
"remove_banner_modal_title": "Remover banner?",
|
||||||
|
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
|
||||||
|
"remove": "Remover",
|
||||||
"background_image_updated": "Imagem de fundo salva",
|
"background_image_updated": "Imagem de fundo salva",
|
||||||
"stats": "Estatísticas",
|
"stats": "Estatísticas",
|
||||||
"achievements": "conquistas",
|
"achievements": "conquistas",
|
||||||
@@ -718,10 +756,10 @@
|
|||||||
"achievements_earned": "Conquistas recebidas",
|
"achievements_earned": "Conquistas recebidas",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
|
|
||||||
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
|
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
|
||||||
"user_reviews": "Avaliações",
|
"user_reviews": "Avaliações",
|
||||||
"loading_reviews": "Carregando avaliações...",
|
"loading_reviews": "Carregando avaliações...",
|
||||||
|
"wrapped_2025": "Wrapped 2025",
|
||||||
"no_reviews": "Ainda não há avaliações",
|
"no_reviews": "Ainda não há avaliações",
|
||||||
"delete_review": "Excluir avaliação"
|
"delete_review": "Excluir avaliação"
|
||||||
},
|
},
|
||||||
@@ -774,5 +812,41 @@
|
|||||||
"all_games": "Todos os Jogos",
|
"all_games": "Todos os Jogos",
|
||||||
"recently_played": "Jogados Recentemente",
|
"recently_played": "Jogados Recentemente",
|
||||||
"favorites": "Favoritos"
|
"favorites": "Favoritos"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Notificações",
|
||||||
|
"mark_all_as_read": "Marcar todas como lidas",
|
||||||
|
"clear_all": "Limpar todas",
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"empty_title": "Sem notificações",
|
||||||
|
"empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.",
|
||||||
|
"empty_filter_description": "Nenhuma notificação corresponde a este filtro.",
|
||||||
|
"filter_all": "Todas",
|
||||||
|
"filter_unread": "Não lidas",
|
||||||
|
"filter_friends": "Amigos",
|
||||||
|
"filter_badges": "Insígnias",
|
||||||
|
"filter_upvotes": "Votos",
|
||||||
|
"filter_local": "Locais",
|
||||||
|
"load_more": "Carregar mais",
|
||||||
|
"dismiss": "Descartar",
|
||||||
|
"accept": "Aceitar",
|
||||||
|
"refuse": "Recusar",
|
||||||
|
"notification": "Notificação",
|
||||||
|
"friend_request_received_title": "Nova solicitação de amizade!",
|
||||||
|
"friend_request_received_description": "{{displayName}} quer ser seu amigo",
|
||||||
|
"friend_request_accepted_title": "Solicitação de amizade aceita!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade",
|
||||||
|
"badge_received_title": "Você recebeu uma nova insígnia!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!",
|
||||||
|
"review_upvote_description": "Sua avaliação recebeu {{count}} novos votos",
|
||||||
|
"marked_all_as_read": "Todas as notificações marcadas como lidas",
|
||||||
|
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
|
||||||
|
"cleared_all": "Todas as notificações limpas",
|
||||||
|
"failed_to_clear": "Falha ao limpar notificações",
|
||||||
|
"failed_to_load": "Falha ao carregar notificações",
|
||||||
|
"failed_to_dismiss": "Falha ao descartar notificação",
|
||||||
|
"friend_request_accepted": "Solicitação de amizade aceita",
|
||||||
|
"friend_request_refused": "Solicitação de amizade recusada"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,7 +508,7 @@
|
|||||||
"show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
|
"show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
|
||||||
"animated_profile_banner": "Banner animado no perfil",
|
"animated_profile_banner": "Banner animado no perfil",
|
||||||
"cloud_saving": "Progresso dos jogos na nuvem",
|
"cloud_saving": "Progresso dos jogos na nuvem",
|
||||||
"hydra_cloud_feature_found": "Descubriste uma funcionalidade Hydra Cloud!",
|
"hydra_cloud_feature_found": "Descobriste uma funcionalidade Hydra Cloud!",
|
||||||
"learn_more": "Saber mais"
|
"learn_more": "Saber mais"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"game_has_no_executable": "Файл запуска игры не выбран",
|
"game_has_no_executable": "Файл запуска игры не выбран",
|
||||||
"sign_in": "Войти",
|
"sign_in": "Войти",
|
||||||
"friends": "Друзья",
|
"friends": "Друзья",
|
||||||
|
"notifications": "Уведомления",
|
||||||
"need_help": "Нужна помощь?",
|
"need_help": "Нужна помощь?",
|
||||||
"favorites": "Избранное",
|
"favorites": "Избранное",
|
||||||
"playable_button_title": "Показать только установленные игры.",
|
"playable_button_title": "Показать только установленные игры.",
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
|
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
|
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
|
||||||
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)",
|
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)",
|
||||||
|
"extracting": "Распаковка {{title}}… ({{percentage}} завершено)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Установка завершена",
|
"installation_complete": "Установка завершена",
|
||||||
"installation_complete_message": "Библиотеки успешно установлены"
|
"installation_complete_message": "Библиотеки успешно установлены"
|
||||||
@@ -148,7 +150,7 @@
|
|||||||
"filter": "Поиск репаков",
|
"filter": "Поиск репаков",
|
||||||
"requirements": "Системные требования",
|
"requirements": "Системные требования",
|
||||||
"minimum": "Минимальные",
|
"minimum": "Минимальные",
|
||||||
"recommended": "Рекомендуемые",
|
"recommended": "Рекомендованные",
|
||||||
"paused": "Приостановлено",
|
"paused": "Приостановлено",
|
||||||
"release_date": "Выпущено {{date}}",
|
"release_date": "Выпущено {{date}}",
|
||||||
"publisher": "Издатель {{publisher}}",
|
"publisher": "Издатель {{publisher}}",
|
||||||
@@ -173,6 +175,7 @@
|
|||||||
"repacks_modal_description": "Выберите репак для загрузки",
|
"repacks_modal_description": "Выберите репак для загрузки",
|
||||||
"select_folder_hint": "Чтобы изменить папку загрузок по умолчанию, откройте <0>Настройки</0>",
|
"select_folder_hint": "Чтобы изменить папку загрузок по умолчанию, откройте <0>Настройки</0>",
|
||||||
"download_now": "Загрузить сейчас",
|
"download_now": "Загрузить сейчас",
|
||||||
|
"loading": "Загрузка...",
|
||||||
"no_shop_details": "Не удалось получить описание",
|
"no_shop_details": "Не удалось получить описание",
|
||||||
"download_options": "Источники",
|
"download_options": "Источники",
|
||||||
"download_path": "Путь для загрузок",
|
"download_path": "Путь для загрузок",
|
||||||
@@ -182,6 +185,11 @@
|
|||||||
"open_screenshot": "Открыть скриншот {{number}}",
|
"open_screenshot": "Открыть скриншот {{number}}",
|
||||||
"download_settings": "Параметры загрузки",
|
"download_settings": "Параметры загрузки",
|
||||||
"downloader": "Загрузчик",
|
"downloader": "Загрузчик",
|
||||||
|
"downloader_online": "Онлайн",
|
||||||
|
"downloader_not_configured": "Доступен, но не настроен",
|
||||||
|
"downloader_offline": "Ссылка недоступна",
|
||||||
|
"downloader_not_available": "Недоступно",
|
||||||
|
"go_to_settings": "Перейти в настройки",
|
||||||
"select_executable": "Выбрать",
|
"select_executable": "Выбрать",
|
||||||
"no_executable_selected": "Файл не выбран",
|
"no_executable_selected": "Файл не выбран",
|
||||||
"open_folder": "Открыть папку",
|
"open_folder": "Открыть папку",
|
||||||
@@ -202,6 +210,7 @@
|
|||||||
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
|
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
|
||||||
"download_in_progress": "Идёт загрузка",
|
"download_in_progress": "Идёт загрузка",
|
||||||
"download_paused": "Загрузка приостановлена",
|
"download_paused": "Загрузка приостановлена",
|
||||||
|
"extracting": "Распаковка",
|
||||||
"last_downloaded_option": "Последний вариант загрузки",
|
"last_downloaded_option": "Последний вариант загрузки",
|
||||||
"new_download_option": "Новый",
|
"new_download_option": "Новый",
|
||||||
"create_steam_shortcut": "Создать ярлык Steam",
|
"create_steam_shortcut": "Создать ярлык Steam",
|
||||||
@@ -233,11 +242,11 @@
|
|||||||
"show_more": "Показать больше",
|
"show_more": "Показать больше",
|
||||||
"show_less": "Показать меньше",
|
"show_less": "Показать меньше",
|
||||||
"reviews": "Отзывы",
|
"reviews": "Отзывы",
|
||||||
|
"review_played_for": "Играли",
|
||||||
"leave_a_review": "Оставить отзыв",
|
"leave_a_review": "Оставить отзыв",
|
||||||
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
|
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
|
||||||
"sort_newest": "Сначала новые",
|
"sort_newest": "Сначала новые",
|
||||||
"no_reviews_yet": "Пока нет отзывов",
|
"no_reviews_yet": "Пока нет отзывов",
|
||||||
"review_played_for": "Играли",
|
|
||||||
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
|
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
|
||||||
"sort_oldest": "Сначала старые",
|
"sort_oldest": "Сначала старые",
|
||||||
"sort_highest_score": "Высший балл",
|
"sort_highest_score": "Высший балл",
|
||||||
@@ -362,8 +371,6 @@
|
|||||||
"audio": "Аудио",
|
"audio": "Аудио",
|
||||||
"filter_by_source": "Фильтр по источнику",
|
"filter_by_source": "Фильтр по источнику",
|
||||||
"no_repacks_found": "Источники для этой игры не найдены",
|
"no_repacks_found": "Источники для этой игры не найдены",
|
||||||
"show": "Показать",
|
|
||||||
"hide": "Скрыть",
|
|
||||||
"delete_review": "Удалить отзыв",
|
"delete_review": "Удалить отзыв",
|
||||||
"remove_review": "Удалить отзыв",
|
"remove_review": "Удалить отзыв",
|
||||||
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
|
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
|
||||||
@@ -375,7 +382,9 @@
|
|||||||
"show_translation": "Показать перевод",
|
"show_translation": "Показать перевод",
|
||||||
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
|
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
|
||||||
"hide_original": "Скрыть оригинал",
|
"hide_original": "Скрыть оригинал",
|
||||||
"review_from_blocked_user": "Отзыв от заблокированного пользователя"
|
"review_from_blocked_user": "Отзыв от заблокированного пользователя",
|
||||||
|
"show": "Показать",
|
||||||
|
"hide": "Скрыть"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Активировать Hydra",
|
"title": "Активировать Hydra",
|
||||||
@@ -394,6 +403,10 @@
|
|||||||
"completed": "Завершено",
|
"completed": "Завершено",
|
||||||
"removed": "Не скачано",
|
"removed": "Не скачано",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
|
"cancel_download": "Отменить загрузку?",
|
||||||
|
"cancel_download_description": "Вы уверены, что хотите отменить эту загрузку? Все загруженные файлы будут удалены.",
|
||||||
|
"keep_downloading": "Нет, продолжить загрузку",
|
||||||
|
"yes_cancel": "Да, отменить",
|
||||||
"filter": "Поиск загруженных игр",
|
"filter": "Поиск загруженных игр",
|
||||||
"remove": "Удалить",
|
"remove": "Удалить",
|
||||||
"downloading_metadata": "Загрузка метаданных…",
|
"downloading_metadata": "Загрузка метаданных…",
|
||||||
@@ -414,7 +427,13 @@
|
|||||||
"resume_seeding": "Продолжить раздачу",
|
"resume_seeding": "Продолжить раздачу",
|
||||||
"options": "Управлять",
|
"options": "Управлять",
|
||||||
"extract": "Распаковать файлы",
|
"extract": "Распаковать файлы",
|
||||||
"extracting": "Распаковка файлов…"
|
"extracting": "Распаковка файлов…",
|
||||||
|
"delete_archive_title": "Хотите удалить {{fileName}}?",
|
||||||
|
"delete_archive_description": "Файл был успешно распакован и больше не нужен.",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"network": "СЕТЬ",
|
||||||
|
"peak": "ПИК"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Путь загрузок",
|
"downloads_path": "Путь загрузок",
|
||||||
@@ -550,6 +569,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
|
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
|
||||||
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
|
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
|
||||||
"enable_steam_achievements": "Включить поиск достижений Steam",
|
"enable_steam_achievements": "Включить поиск достижений Steam",
|
||||||
|
"enable_new_download_options_badges": "Показывать значки новых вариантов загрузки",
|
||||||
"achievement_custom_notification_position": "Позиция уведомлений достижений",
|
"achievement_custom_notification_position": "Позиция уведомлений достижений",
|
||||||
"top-left": "Верхний левый угол",
|
"top-left": "Верхний левый угол",
|
||||||
"top-center": "Верхний центр",
|
"top-center": "Верхний центр",
|
||||||
@@ -567,6 +587,9 @@
|
|||||||
"test_notification": "Тестовое уведомление",
|
"test_notification": "Тестовое уведомление",
|
||||||
"achievement_sound_volume": "Громкость звука достижения",
|
"achievement_sound_volume": "Громкость звука достижения",
|
||||||
"select_achievement_sound": "Выбрать звук достижения",
|
"select_achievement_sound": "Выбрать звук достижения",
|
||||||
|
"change_achievement_sound": "Изменить звук достижения",
|
||||||
|
"remove_achievement_sound": "Удалить звук достижения",
|
||||||
|
"preview_sound": "Предпросмотр звука",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"preview": "Предпросмотр",
|
"preview": "Предпросмотр",
|
||||||
"remove": "Удалить",
|
"remove": "Удалить",
|
||||||
@@ -574,7 +597,10 @@
|
|||||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||||
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
|
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
|
||||||
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры"
|
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры",
|
||||||
|
"downloads": "Загрузки",
|
||||||
|
"use_native_http_downloader": "Использовать встроенный HTTP-загрузчик (экспериментально)",
|
||||||
|
"cannot_change_downloader_while_downloading": "Нельзя изменить эту настройку во время загрузки"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Загрузка завершена",
|
"download_complete": "Загрузка завершена",
|
||||||
@@ -651,6 +677,7 @@
|
|||||||
"sending": "Отправка",
|
"sending": "Отправка",
|
||||||
"friend_request_sent": "Запрос в друзья отправлен",
|
"friend_request_sent": "Запрос в друзья отправлен",
|
||||||
"friends": "Друзья",
|
"friends": "Друзья",
|
||||||
|
"badges": "Значки",
|
||||||
"friends_list": "Список друзей",
|
"friends_list": "Список друзей",
|
||||||
"user_not_found": "Пользователь не найден",
|
"user_not_found": "Пользователь не найден",
|
||||||
"block_user": "Заблокировать пользователя",
|
"block_user": "Заблокировать пользователя",
|
||||||
@@ -661,12 +688,17 @@
|
|||||||
"ignore_request": "Игнорировать запрос",
|
"ignore_request": "Игнорировать запрос",
|
||||||
"cancel_request": "Отменить запрос",
|
"cancel_request": "Отменить запрос",
|
||||||
"undo_friendship": "Удалить друга",
|
"undo_friendship": "Удалить друга",
|
||||||
|
"friendship_removed": "Друг удален",
|
||||||
"request_accepted": "Запрос принят",
|
"request_accepted": "Запрос принят",
|
||||||
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
||||||
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
||||||
"blocked_users": "Заблокированные пользователи",
|
"blocked_users": "Заблокированные пользователи",
|
||||||
"unblock": "Разблокировать",
|
"unblock": "Разблокировать",
|
||||||
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
||||||
|
"no_friends_yet": "Вы ещё не добавили ни одного друга",
|
||||||
|
"view_all": "Показать все",
|
||||||
|
"load_more": "Загрузить еще",
|
||||||
|
"loading": "Загрузка",
|
||||||
"pending": "Ожидание",
|
"pending": "Ожидание",
|
||||||
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
||||||
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
||||||
@@ -689,9 +721,17 @@
|
|||||||
"report_reason_spam": "Спам",
|
"report_reason_spam": "Спам",
|
||||||
"report_reason_other": "Другое",
|
"report_reason_other": "Другое",
|
||||||
"profile_reported": "Жалоба на профиль отправлена",
|
"profile_reported": "Жалоба на профиль отправлена",
|
||||||
"your_friend_code": "Код вашего друга:",
|
"your_friend_code": "Ваш код друга:",
|
||||||
|
"copy_friend_code": "Копировать код друга",
|
||||||
|
"copied": "Скопировано!",
|
||||||
"upload_banner": "Загрузить баннер",
|
"upload_banner": "Загрузить баннер",
|
||||||
"uploading_banner": "Загрузка баннера...",
|
"uploading_banner": "Загрузка баннера...",
|
||||||
|
"change_banner": "Изменить баннер",
|
||||||
|
"replace_banner": "Заменить баннер",
|
||||||
|
"remove_banner": "Удалить баннер",
|
||||||
|
"remove_banner_modal_title": "Удалить баннер?",
|
||||||
|
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
|
||||||
|
"remove": "Удалить",
|
||||||
"background_image_updated": "Фоновое изображение обновлено",
|
"background_image_updated": "Фоновое изображение обновлено",
|
||||||
"stats": "Статистика",
|
"stats": "Статистика",
|
||||||
"achievements": "Достижения",
|
"achievements": "Достижения",
|
||||||
@@ -709,11 +749,31 @@
|
|||||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||||
"karma": "Карма",
|
"karma": "Карма",
|
||||||
"karma_count": "карма",
|
"karma_count": "карма",
|
||||||
"karma_description": "Заработана положительными оценками отзывов",
|
|
||||||
"user_reviews": "Отзывы",
|
"user_reviews": "Отзывы",
|
||||||
|
"delete_review": "Удалить отзыв",
|
||||||
"loading_reviews": "Загрузка отзывов...",
|
"loading_reviews": "Загрузка отзывов...",
|
||||||
"no_reviews": "Пока нет отзывов",
|
"wrapped_2025": "Wrapped 2025"
|
||||||
"delete_review": "Удалить отзыв"
|
},
|
||||||
|
"library": {
|
||||||
|
"library": "Библиотека",
|
||||||
|
"play": "Играть",
|
||||||
|
"download": "Скачать",
|
||||||
|
"downloading": "Скачивание",
|
||||||
|
"game": "игра",
|
||||||
|
"games": "игры",
|
||||||
|
"grid_view": "Вид сетки",
|
||||||
|
"compact_view": "Компактный вид",
|
||||||
|
"large_view": "Большой вид",
|
||||||
|
"no_games_title": "Ваша библиотека пуста",
|
||||||
|
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
|
||||||
|
"amount_hours": "{{amount}} часов",
|
||||||
|
"amount_minutes": "{{amount}} минут",
|
||||||
|
"amount_hours_short": "{{amount}}ч",
|
||||||
|
"amount_minutes_short": "{{amount}}м",
|
||||||
|
"manual_playtime_tooltip": "Время игры было обновлено вручную",
|
||||||
|
"all_games": "Все игры",
|
||||||
|
"recently_played": "Недавно сыгранные",
|
||||||
|
"favorites": "Избранное"
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Достижение разблокировано",
|
"achievement_unlocked": "Достижение разблокировано",
|
||||||
@@ -744,25 +804,40 @@
|
|||||||
"learn_more": "Подробнее",
|
"learn_more": "Подробнее",
|
||||||
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
|
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
|
||||||
},
|
},
|
||||||
"library": {
|
"notifications_page": {
|
||||||
"library": "Библиотека",
|
"title": "Уведомления",
|
||||||
"play": "Играть",
|
"mark_all_as_read": "Отметить все как прочитанные",
|
||||||
"download": "Скачать",
|
"clear_all": "Очистить все",
|
||||||
"downloading": "Скачивание",
|
"loading": "Загрузка...",
|
||||||
"game": "игра",
|
"empty_title": "Нет уведомлений",
|
||||||
"games": "игры",
|
"empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
|
||||||
"grid_view": "Вид сетки",
|
"empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
|
||||||
"compact_view": "Компактный вид",
|
"filter_all": "Все",
|
||||||
"large_view": "Большой вид",
|
"filter_unread": "Непрочитанные",
|
||||||
"no_games_title": "Ваша библиотека пуста",
|
"filter_friends": "Друзья",
|
||||||
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
|
"filter_badges": "Значки",
|
||||||
"amount_hours": "{{amount}} часов",
|
"filter_upvotes": "Голоса",
|
||||||
"amount_minutes": "{{amount}} минут",
|
"filter_local": "Локальные",
|
||||||
"amount_hours_short": "{{amount}}ч",
|
"load_more": "Загрузить еще",
|
||||||
"amount_minutes_short": "{{amount}}м",
|
"dismiss": "Отклонить",
|
||||||
"manual_playtime_tooltip": "Время игры было обновлено вручную",
|
"accept": "Принять",
|
||||||
"all_games": "Все игры",
|
"refuse": "Отклонить",
|
||||||
"recently_played": "Недавно сыгранные",
|
"notification": "Уведомление",
|
||||||
"favorites": "Избранное"
|
"friend_request_received_title": "Новый запрос в друзья!",
|
||||||
|
"friend_request_received_description": "{{displayName}} хочет добавить вас в друзья",
|
||||||
|
"friend_request_accepted_title": "Запрос в друзья принят!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья",
|
||||||
|
"badge_received_title": "Вы получили новый значок!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!",
|
||||||
|
"review_upvote_description": "Ваш отзыв получил {{count}} новых голосов",
|
||||||
|
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
|
||||||
|
"failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные",
|
||||||
|
"cleared_all": "Все уведомления очищены",
|
||||||
|
"failed_to_clear": "Не удалось очистить уведомления",
|
||||||
|
"failed_to_load": "Не удалось загрузить уведомления",
|
||||||
|
"failed_to_dismiss": "Не удалось отклонить уведомление",
|
||||||
|
"friend_request_accepted": "Запрос в друзья принят",
|
||||||
|
"friend_request_refused": "Запрос в друзья отклонен"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
844
src/locales/sl/translation.json
Normal file
844
src/locales/sl/translation.json
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
{
|
||||||
|
"language_name": "Slovenščina",
|
||||||
|
"app": {
|
||||||
|
"successfully_signed_in": "Uspešno ste se prijavili"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"surprise_me": "Preseneti me",
|
||||||
|
"no_results": "Ni najdenih rezultatov",
|
||||||
|
"start_typing": "Začnite tipkati za iskanje...",
|
||||||
|
"hot": "Trenutno vroče",
|
||||||
|
"weekly": "📅 Najboljše igre tedna",
|
||||||
|
"achievements": "🏆 Igre za premagati"
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"catalogue": "Katalog",
|
||||||
|
"library": "Knjižnica",
|
||||||
|
"downloads": "Prenosi",
|
||||||
|
"settings": "Nastavitve",
|
||||||
|
"my_library": "Moja knjižnica",
|
||||||
|
"downloading_metadata": "{{title}} (Prenos metapodatkov…)",
|
||||||
|
"paused": "{{title}} (V premoru)",
|
||||||
|
"downloading": "{{title}} ({{percentage}} - Prenos…)",
|
||||||
|
"filter": "Filtriraj knjižnico",
|
||||||
|
"home": "Domov",
|
||||||
|
"queued": "{{title}} (V čakalni vrsti)",
|
||||||
|
"game_has_no_executable": "Igra nima izbrane izvršljive datoteke",
|
||||||
|
"sign_in": "Prijavite se",
|
||||||
|
"friends": "Prijatelji",
|
||||||
|
"notifications": "Obvestila",
|
||||||
|
"need_help": "Potrebujete pomoč?",
|
||||||
|
"favorites": "Priljubljene",
|
||||||
|
"playable_button_title": "Pokaži le igre, ki jih lahko igrate zdaj",
|
||||||
|
"add_custom_game_tooltip": "Dodaj igro po meri",
|
||||||
|
"show_playable_only_tooltip": "Pokaži samo igrljive",
|
||||||
|
"custom_game_modal": "Dodaj igro po meri",
|
||||||
|
"custom_game_modal_description": "Dodajte igro po meri v vašo knjižnico z izbiro izvršljive datoteke",
|
||||||
|
"custom_game_modal_executable_path": "Pot do izvršljive datoteke",
|
||||||
|
"custom_game_modal_select_executable": "Izberite izvršljivo datoteko",
|
||||||
|
"custom_game_modal_title": "Naslov",
|
||||||
|
"custom_game_modal_enter_title": "Vnesite naslov",
|
||||||
|
"custom_game_modal_browse": "Brskaj",
|
||||||
|
"custom_game_modal_cancel": "Prekliči",
|
||||||
|
"custom_game_modal_add": "Dodaj igro",
|
||||||
|
"custom_game_modal_adding": "Dodajanje igre...",
|
||||||
|
"custom_game_modal_success": "Igra po meri je bila uspešno dodana",
|
||||||
|
"custom_game_modal_failed": "Dodajanje igre po meri ni uspelo",
|
||||||
|
"custom_game_modal_executable": "Izvršljiva datoteka",
|
||||||
|
"edit_game_modal": "Prilagodi sredstva",
|
||||||
|
"edit_game_modal_description": "Prilagodite sredstva in podrobnosti igre",
|
||||||
|
"edit_game_modal_title": "Naslov",
|
||||||
|
"edit_game_modal_enter_title": "Vnesite naslov",
|
||||||
|
"edit_game_modal_image": "Slika",
|
||||||
|
"edit_game_modal_select_image": "Izberite sliko",
|
||||||
|
"edit_game_modal_browse": "Brskaj",
|
||||||
|
"edit_game_modal_image_preview": "Predogled slike",
|
||||||
|
"edit_game_modal_icon": "Ikona",
|
||||||
|
"edit_game_modal_select_icon": "Izberite ikono",
|
||||||
|
"edit_game_modal_icon_preview": "Predogled ikone",
|
||||||
|
"edit_game_modal_logo": "Logotip",
|
||||||
|
"edit_game_modal_select_logo": "Izberite logotip",
|
||||||
|
"edit_game_modal_logo_preview": "Predogled logotipa",
|
||||||
|
"edit_game_modal_hero": "Hero knjižnice",
|
||||||
|
"edit_game_modal_select_hero": "Izberite sliko hero knjižnice",
|
||||||
|
"edit_game_modal_hero_preview": "Predogled hero slike knjižnice",
|
||||||
|
"edit_game_modal_cancel": "Prekliči",
|
||||||
|
"edit_game_modal_update": "Posodobi",
|
||||||
|
"edit_game_modal_updating": "Posodabljanje...",
|
||||||
|
"edit_game_modal_fill_required": "Prosimo, izpolnite vsa obvezna polja",
|
||||||
|
"edit_game_modal_success": "Sredstva so bila uspešno posodobljena",
|
||||||
|
"edit_game_modal_failed": "Posodabljanje sredstev ni uspelo",
|
||||||
|
"edit_game_modal_image_filter": "Slika",
|
||||||
|
"edit_game_modal_icon_resolution": "Priporočena resolucija: 256x256px",
|
||||||
|
"edit_game_modal_logo_resolution": "Priporočena resolucija: 640x360px",
|
||||||
|
"edit_game_modal_hero_resolution": "Priporočena resolucija: 1920x620px",
|
||||||
|
"edit_game_modal_assets": "Sredstva",
|
||||||
|
"edit_game_modal_drop_icon_image_here": "Spustite ikono tukaj",
|
||||||
|
"edit_game_modal_drop_logo_image_here": "Spustite logotip tukaj",
|
||||||
|
"edit_game_modal_drop_hero_image_here": "Spustite hero sliko tukaj",
|
||||||
|
"edit_game_modal_drop_to_replace_icon": "Spustite za zamenjavo ikone",
|
||||||
|
"edit_game_modal_drop_to_replace_logo": "Spustite za zamenjavo logotipa",
|
||||||
|
"edit_game_modal_drop_to_replace_hero": "Spustite za zamenjavo hero slike",
|
||||||
|
"install_decky_plugin": "Namesti Decky vtičnik",
|
||||||
|
"update_decky_plugin": "Posodobi Decky vtičnik",
|
||||||
|
"decky_plugin_installed_version": "Decky vtičnik (v{{version}})",
|
||||||
|
"install_decky_plugin_title": "Namesti Hydra Decky vtičnik",
|
||||||
|
"install_decky_plugin_message": "To bo preneslo in namestilo Hydra vtičnik za Decky Loader. To lahko zahteva povišane pravice. Nadaljujem?",
|
||||||
|
"update_decky_plugin_title": "Posodobi Hydra Decky vtičnik",
|
||||||
|
"update_decky_plugin_message": "Na voljo je nova različica Hydra Decky vtičnika. Ali želite posodobiti zdaj?",
|
||||||
|
"decky_plugin_installed": "Decky vtičnik v{{version}} je bil uspešno nameščen",
|
||||||
|
"decky_plugin_installation_failed": "Namestitev Decky vtičnika ni uspela: {{error}}",
|
||||||
|
"decky_plugin_installation_error": "Napaka pri nameščanju Decky vtičnika: {{error}}",
|
||||||
|
"confirm": "Potrdi",
|
||||||
|
"cancel": "Prekliči"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"search": "Išči igre",
|
||||||
|
"search_library": "Išči v knjižnici",
|
||||||
|
"recent_searches": "Nedavna iskanja",
|
||||||
|
"suggestions": "Predlogi",
|
||||||
|
"clear_history": "Počisti",
|
||||||
|
"remove_from_history": "Odstrani iz zgodovine",
|
||||||
|
"loading": "Nalaganje...",
|
||||||
|
"no_results": "Ni rezultatov",
|
||||||
|
"home": "Domov",
|
||||||
|
"catalogue": "Katalog",
|
||||||
|
"library": "Knjižnica",
|
||||||
|
"downloads": "Prenosi",
|
||||||
|
"search_results": "Rezultati iskanja",
|
||||||
|
"settings": "Nastavitve",
|
||||||
|
"version_available_install": "Različica {{version}} je na voljo. Kliknite tukaj za ponovni zagon in namestitev.",
|
||||||
|
"version_available_download": "Različica {{version}} je na voljo. Kliknite tukaj za prenos."
|
||||||
|
},
|
||||||
|
"bottom_panel": {
|
||||||
|
"no_downloads_in_progress": "Ni prenosa v teku",
|
||||||
|
"downloading_metadata": "Prenos metapodatkov {{title}}…",
|
||||||
|
"downloading": "Prenos {{title}}… ({{percentage}} končano) - Čas {{eta}} - {{speed}}",
|
||||||
|
"calculating_eta": "Prenos {{title}}… ({{percentage}} končano) - Izračun preostalega časa…",
|
||||||
|
"checking_files": "Preverjanje datotek {{title}}… ({{percentage}} končano)",
|
||||||
|
"extracting": "Razpakiranje {{title}}… ({{percentage}} končano)",
|
||||||
|
"installing_common_redist": "{{log}}…",
|
||||||
|
"installation_complete": "Namestitev zaključena",
|
||||||
|
"installation_complete_message": "Skupni redistributables so bili uspešno nameščeni"
|
||||||
|
},
|
||||||
|
"catalogue": {
|
||||||
|
"search": "Filtriraj…",
|
||||||
|
"developers": "Razvijalci",
|
||||||
|
"genres": "Žanri",
|
||||||
|
"tags": "Oznake",
|
||||||
|
"publishers": "Izdajatelji",
|
||||||
|
"download_sources": "Viri prenosa",
|
||||||
|
"result_count": "{{resultCount}} rezultatov",
|
||||||
|
"filter_count": "{{filterCount}} na voljo",
|
||||||
|
"clear_filters": "Počisti {{filterCount}} izbranih"
|
||||||
|
},
|
||||||
|
"game_details": {
|
||||||
|
"open_download_options": "Odpri možnosti prenosa",
|
||||||
|
"download_options_zero": "Ni možnosti prenosa",
|
||||||
|
"download_options_one": "{{count}} možnost prenosa",
|
||||||
|
"download_options_other": "{{count}} možnosti prenosa",
|
||||||
|
"updated_at": "Posodobljeno {{updated_at}}",
|
||||||
|
"install": "Namesti",
|
||||||
|
"resume": "Nadaljuj",
|
||||||
|
"pause": "Premor",
|
||||||
|
"cancel": "Prekliči",
|
||||||
|
"remove": "Odstrani",
|
||||||
|
"space_left_on_disk": "{{space}} prosto na disku",
|
||||||
|
"eta": "Zaključek {{eta}}",
|
||||||
|
"calculating_eta": "Izračun preostalega časa…",
|
||||||
|
"downloading_metadata": "Prenos metapodatkov…",
|
||||||
|
"filter": "Filtriraj repake",
|
||||||
|
"requirements": "Sistemske zahteve",
|
||||||
|
"minimum": "Minimum",
|
||||||
|
"recommended": "Priporočeno",
|
||||||
|
"paused": "V premoru",
|
||||||
|
"release_date": "Izid dne {{date}}",
|
||||||
|
"publisher": "Objavljeno s strani {{publisher}}",
|
||||||
|
"hours": "ur",
|
||||||
|
"minutes": "minut",
|
||||||
|
"amount_hours": "{{amount}} ur",
|
||||||
|
"amount_minutes": "{{amount}} minut",
|
||||||
|
"accuracy": "{{accuracy}}% natančnost",
|
||||||
|
"add_to_library": "Dodaj v knjižnico",
|
||||||
|
"already_in_library": "Že v knjižnici",
|
||||||
|
"remove_from_library": "Odstrani iz knjižnice",
|
||||||
|
"no_downloads": "Ni razpoložljivih prenosov",
|
||||||
|
"play_time": "Odigrano {{amount}}",
|
||||||
|
"last_time_played": "Nazadnje igrano {{period}}",
|
||||||
|
"not_played_yet": "Še niste igrali {{title}}",
|
||||||
|
"next_suggestion": "Naslednji predlog",
|
||||||
|
"play": "Igraj",
|
||||||
|
"deleting": "Brisanje namestitvenega programa…",
|
||||||
|
"close": "Zapri",
|
||||||
|
"playing_now": "Trenutno igranje",
|
||||||
|
"change": "Spremeni",
|
||||||
|
"repacks_modal_description": "Izberite repak, ki ga želite prenesti",
|
||||||
|
"select_folder_hint": "Za spremembo privzete mape pojdite v <0>Nastavitve</0>",
|
||||||
|
"download_now": "Prenesi zdaj",
|
||||||
|
"loading": "Nalaganje...",
|
||||||
|
"no_shop_details": "Podatkov o trgovini ni bilo mogoče pridobiti.",
|
||||||
|
"download_options": "Možnosti prenosa",
|
||||||
|
"download_path": "Pot prenosa",
|
||||||
|
"previous_screenshot": "Prejšnji posnetek zaslona",
|
||||||
|
"next_screenshot": "Naslednji posnetek zaslona",
|
||||||
|
"screenshot": "Posnetek zaslona {{number}}",
|
||||||
|
"open_screenshot": "Odpri posnetek zaslona {{number}}",
|
||||||
|
"download_settings": "Nastavitve prenosa",
|
||||||
|
"downloader": "Prenosnik",
|
||||||
|
"downloader_online": "Spletno",
|
||||||
|
"downloader_not_configured": "Na voljo, vendar ni nastavljeno",
|
||||||
|
"downloader_offline": "Povezava je brez povezave",
|
||||||
|
"downloader_not_available": "Ni na voljo",
|
||||||
|
"recommended": "Priporočeno",
|
||||||
|
"go_to_settings": "Pojdi v nastavitve",
|
||||||
|
"select_executable": "Izberi",
|
||||||
|
"no_executable_selected": "Ni izbrane izvršljive datoteke",
|
||||||
|
"open_folder": "Odpri mapo",
|
||||||
|
"open_download_location": "Poglej prenesene datoteke",
|
||||||
|
"create_shortcut": "Ustvari bližnjico na namizju",
|
||||||
|
"create_shortcut_simple": "Ustvari bližnjico",
|
||||||
|
"clear": "Počisti",
|
||||||
|
"remove_files": "Odstrani datoteke",
|
||||||
|
"remove_from_library_title": "Ali ste prepričani?",
|
||||||
|
"remove_from_library_description": "To bo odstranilo {{game}} iz vaše knjižnice",
|
||||||
|
"options": "Možnosti",
|
||||||
|
"properties": "Lastnosti",
|
||||||
|
"executable_section_title": "Izvršljiva datoteka",
|
||||||
|
"executable_section_description": "Pot do datoteke, ki se bo izvedla ob kliku na \"Igraj\"",
|
||||||
|
"downloads_section_title": "Prenosi",
|
||||||
|
"downloads_section_description": "Preverite posodobitve ali druge različice te igre",
|
||||||
|
"danger_zone_section_title": "Nevarno območje",
|
||||||
|
"danger_zone_section_description": "Odstranite to igro iz knjižnice ali datoteke, ki jih je prenesel Hydra",
|
||||||
|
"download_in_progress": "Prenos v teku",
|
||||||
|
"download_paused": "Prenos v premoru",
|
||||||
|
"extracting": "Razpakiranje",
|
||||||
|
"last_downloaded_option": "Zadnja prenesena možnost",
|
||||||
|
"new_download_option": "Novo",
|
||||||
|
"create_steam_shortcut": "Ustvari Steam bližnjico",
|
||||||
|
"create_shortcut_success": "Bližnjica je bila uspešno ustvarjena",
|
||||||
|
"you_might_need_to_restart_steam": "Morda boste morali ponovno zagnati Steam, da vidite spremembe",
|
||||||
|
"create_shortcut_error": "Napaka pri ustvarjanju bližnjice",
|
||||||
|
"add_to_favorites": "Dodaj med priljubljene",
|
||||||
|
"remove_from_favorites": "Odstrani iz priljubljenih",
|
||||||
|
"failed_update_favorites": "Posodabljanje priljubljenih ni uspelo",
|
||||||
|
"game_removed_from_library": "Igra odstranjena iz knjižnice",
|
||||||
|
"failed_remove_from_library": "Odstranjevanje iz knjižnice ni uspelo",
|
||||||
|
"files_removed_success": "Datoteke so bile uspešno odstranjene",
|
||||||
|
"failed_remove_files": "Odstranjevanje datotek ni uspelo",
|
||||||
|
"nsfw_content_title": "Ta igra vsebuje neprimerno vsebino",
|
||||||
|
"nsfw_content_description": "{{title}} vsebuje vsebino, ki morda ni primerna za vse starosti. Ali ste prepričani, da želite nadaljevati?",
|
||||||
|
"allow_nsfw_content": "Nadaljuj",
|
||||||
|
"refuse_nsfw_content": "Nazaj",
|
||||||
|
"stats": "Statistika",
|
||||||
|
"download_count": "Prenosi",
|
||||||
|
"player_count": "Aktivni igralci",
|
||||||
|
"rating_count": "Ocena",
|
||||||
|
"download_error": "Ta možnost prenosa ni na voljo",
|
||||||
|
"download": "Prenesi",
|
||||||
|
"executable_path_in_use": "Izvršljiva datoteka že uporablja \"{{game}}\"",
|
||||||
|
"warning": "Opozorilo:",
|
||||||
|
"hydra_needs_to_remain_open": "Za ta prenos mora Hydra ostati odprta, dokler ni končana. Če se Hydra zapre pred končanim prenosom, boste izgubili napredek.",
|
||||||
|
"achievements": "Dosežki",
|
||||||
|
"achievements_count": "Dosežki {{unlockedCount}}/{{achievementsCount}}",
|
||||||
|
"show_more": "Pokaži več",
|
||||||
|
"show_less": "Pokaži manj",
|
||||||
|
"reviews": "Mnenja",
|
||||||
|
"review_played_for": "Odigrano za",
|
||||||
|
"leave_a_review": "Oddajte mnenje",
|
||||||
|
"write_review_placeholder": "Delite svoje misli o tej igri...",
|
||||||
|
"sort_newest": "Najnovejše",
|
||||||
|
"no_reviews_yet": "Ni še mnenj",
|
||||||
|
"be_first_to_review": "Bodite prvi, ki delite svoje misli o tej igri!",
|
||||||
|
"sort_oldest": "Najstarejše",
|
||||||
|
"sort_highest_score": "Najvišja ocena",
|
||||||
|
"sort_lowest_score": "Najnižja ocena",
|
||||||
|
"sort_most_voted": "Največ glasov",
|
||||||
|
"rating": "Ocena",
|
||||||
|
"rating_stats": "Ocena",
|
||||||
|
"rating_very_negative": "Zelo negativno",
|
||||||
|
"rating_negative": "Negativno",
|
||||||
|
"rating_neutral": "Nevtralno",
|
||||||
|
"rating_positive": "Pozitivno",
|
||||||
|
"rating_very_positive": "Zelo pozitivno",
|
||||||
|
"submit_review": "Pošlji",
|
||||||
|
"submitting": "Pošiljanje...",
|
||||||
|
"review_submitted_successfully": "Mnenje je bilo uspešno poslano!",
|
||||||
|
"review_submission_failed": "Pošiljanje mnenja ni uspelo. Prosimo, poskusite znova.",
|
||||||
|
"review_cannot_be_empty": "Polje mnenja ne sme biti prazno.",
|
||||||
|
"review_deleted_successfully": "Mnenje je bilo uspešno izbrisano.",
|
||||||
|
"review_deletion_failed": "Brisanje mnenja ni uspelo. Prosimo, poskusite znova.",
|
||||||
|
"loading_reviews": "Nalagam mnenja...",
|
||||||
|
"loading_more_reviews": "Nalagam več mnenj...",
|
||||||
|
"load_more_reviews": "Naloži več mnenj",
|
||||||
|
"you_seemed_to_enjoy_this_game": "Zdi se, da uživate v tej igri",
|
||||||
|
"would_you_recommend_this_game": "Bi radi oddali mnenje o tej igri?",
|
||||||
|
"yes": "Da",
|
||||||
|
"maybe_later": "Mogoče kasneje",
|
||||||
|
"cloud_save": "Shranjevanje v oblaku",
|
||||||
|
"cloud_save_description": "Shranjujte napredek v oblak in nadaljujte igranje na katerikoli napravi",
|
||||||
|
"backups": "Varnostne kopije",
|
||||||
|
"install_backup": "Namesti",
|
||||||
|
"delete_backup": "Izbriši",
|
||||||
|
"create_backup": "Nova varnostna kopija",
|
||||||
|
"last_backup_date": "Zadnja varnostna kopija {{date}}",
|
||||||
|
"no_backup_preview": "Ni shranjenih iger za ta naslov",
|
||||||
|
"restoring_backup": "Obnavljanje varnostne kopije ({{progress}} končano)…",
|
||||||
|
"uploading_backup": "Nalaganje varnostne kopije…",
|
||||||
|
"no_backups": "Za to igro še niste ustvarili varnostnih kopij",
|
||||||
|
"backup_uploaded": "Varnostna kopija naložena",
|
||||||
|
"backup_failed": "Varnostna kopija ni uspela",
|
||||||
|
"backup_deleted": "Varnostna kopija izbrisana",
|
||||||
|
"backup_restored": "Varnostna kopija obnovljena",
|
||||||
|
"see_all_achievements": "Poglej vse dosežke",
|
||||||
|
"sign_in_to_see_achievements": "Prijavite se za ogled dosežkov",
|
||||||
|
"mapping_method_automatic": "Samodejno",
|
||||||
|
"mapping_method_manual": "Ročno",
|
||||||
|
"mapping_method_label": "Način mapiranja",
|
||||||
|
"files_automatically_mapped": "Datoteke so samodejno preslikane",
|
||||||
|
"no_backups_created": "Za to igro ni ustvarjenih varnostnih kopij",
|
||||||
|
"manage_files": "Upravljaj datoteke",
|
||||||
|
"loading_save_preview": "Iskanje shranjenih iger…",
|
||||||
|
"wine_prefix": "Wine predpona",
|
||||||
|
"wine_prefix_description": "Wine predpona, uporabljena za zagon te igre",
|
||||||
|
"launch_options": "Možnosti zagona",
|
||||||
|
"launch_options_description": "Napredni uporabniki lahko vpišejo spremembe v možnosti zagona (eksperimentalna funkcija)",
|
||||||
|
"launch_options_placeholder": "Ni določenega parametra",
|
||||||
|
"no_download_option_info": "Ni razpoložljivih informacij",
|
||||||
|
"backup_deletion_failed": "Brisanje varnostne kopije ni uspelo",
|
||||||
|
"max_number_of_artifacts_reached": "Doseženo je največje število varnostnih kopij za to igro",
|
||||||
|
"achievements_not_sync": "Oglejte si, kako sinhronizirati svoje dosežke",
|
||||||
|
"manage_files_description": "Upravljajte, katere datoteke bodo varnostno kopirane in obnovljene",
|
||||||
|
"select_folder": "Izberite mapo",
|
||||||
|
"backup_from": "Varnostna kopija od {{date}}",
|
||||||
|
"automatic_backup_from": "Samodejna varnostna kopija od {{date}}",
|
||||||
|
"enable_automatic_cloud_sync": "Omogoči samodejno sinhronizacijo v oblaku",
|
||||||
|
"custom_backup_location_set": "Nastavljena je po meri lokacija varnostne kopije",
|
||||||
|
"no_directory_selected": "Ni izbrane mape",
|
||||||
|
"no_write_permission": "V to mapo ni mogoče prenesti. Kliknite tukaj za več informacij.",
|
||||||
|
"reset_achievements": "Ponastavi dosežke",
|
||||||
|
"reset_achievements_description": "To bo ponastavilo vse dosežke za {{game}}",
|
||||||
|
"reset_achievements_title": "Ali ste prepričani?",
|
||||||
|
"reset_achievements_success": "Dosežki so bili uspešno ponastavljeni",
|
||||||
|
"reset_achievements_error": "Ponastavitev dosežkov ni uspela",
|
||||||
|
"download_error_gofile_quota_exceeded": "Presegli ste mesečno kvoto Gofile. Prosimo, počakajte, da se kvota ponastavi.",
|
||||||
|
"download_error_real_debrid_account_not_authorized": "Vaš račun Real-Debrid ni pooblaščen za nove prenose. Preverite nastavitve računa in poskusite znova.",
|
||||||
|
"download_error_not_cached_on_real_debrid": "Ta prenos ni na voljo v Real-Debrid in preverjanje statusa prenosa iz Real-Debrid še ni na voljo.",
|
||||||
|
"update_playtime_title": "Posodobi čas igranja",
|
||||||
|
"update_playtime_description": "Ročno posodobite čas igranja za {{game}}",
|
||||||
|
"update_playtime": "Posodobi čas igranja",
|
||||||
|
"update_playtime_success": "Čas igranja je bil uspešno posodobljen",
|
||||||
|
"update_playtime_error": "Posodabljanje časa igranja ni uspelo",
|
||||||
|
"update_game_playtime": "Posodobi čas igranja igre",
|
||||||
|
"manual_playtime_warning": "Vaše ure bodo označene kot ročno posodobljene, tega ni mogoče razveljaviti.",
|
||||||
|
"manual_playtime_tooltip": "Ta čas igranja je bil ročno posodobljen",
|
||||||
|
"download_error_not_cached_on_torbox": "Ta prenos ni na voljo v TorBox in preverjanje statusa prenosa iz TorBox še ni na voljo.",
|
||||||
|
"download_error_not_cached_on_hydra": "Ta prenos ni na voljo v Nimbus.",
|
||||||
|
"game_removed_from_favorites": "Igra odstranjena iz priljubljenih",
|
||||||
|
"game_added_to_favorites": "Igra dodana med priljubljene",
|
||||||
|
"game_removed_from_pinned": "Igra odstranjena iz pripetih",
|
||||||
|
"game_added_to_pinned": "Igra pripeta",
|
||||||
|
"automatically_extract_downloaded_files": "Samodejno razpakiraj prenesene datoteke",
|
||||||
|
"create_start_menu_shortcut": "Ustvari bližnjico v Start meniju",
|
||||||
|
"invalid_wine_prefix_path": "Neveljavna pot Wine predpone",
|
||||||
|
"invalid_wine_prefix_path_description": "Pot do Wine predpone je neveljavna. Preverite pot in poskusite znova.",
|
||||||
|
"missing_wine_prefix": "Wine predpona je potrebna za ustvarjanje varnostne kopije na Linuxu",
|
||||||
|
"artifact_renamed": "Varnostna kopija je bila uspešno preimenovana",
|
||||||
|
"rename_artifact": "Preimenuj varnostno kopijo",
|
||||||
|
"rename_artifact_description": "Preimenujte varnostno kopijo v bolj opisno ime",
|
||||||
|
"artifact_name_label": "Ime varnostne kopije",
|
||||||
|
"artifact_name_placeholder": "Vnesite ime varnostne kopije",
|
||||||
|
"save_changes": "Shrani spremembe",
|
||||||
|
"required_field": "To polje je obvezno",
|
||||||
|
"max_length_field": "To polje mora biti krajše od {{length}} znakov",
|
||||||
|
"freeze_backup": "Pripni, da jo samodejne varnostne kopije ne prepišejo",
|
||||||
|
"unfreeze_backup": "Odpni",
|
||||||
|
"backup_frozen": "Varnostna kopija je pripeta",
|
||||||
|
"backup_unfrozen": "Varnostna kopija je odprijeta",
|
||||||
|
"backup_freeze_failed": "Pripenjanje varnostne kopije ni uspelo",
|
||||||
|
"backup_freeze_failed_description": "Morate pustiti vsaj en prost prostor za samodejne varnostne kopije",
|
||||||
|
"edit_game_modal_button": "Prilagodi sredstva igre",
|
||||||
|
"game_details": "Podrobnosti igre",
|
||||||
|
"currency_symbol": "$",
|
||||||
|
"currency_country": "us",
|
||||||
|
"prices": "Cene",
|
||||||
|
"no_prices_found": "Ni najdenih cen",
|
||||||
|
"view_all_prices": "Kliknite za ogled vseh cen",
|
||||||
|
"retail_price": "Maloprodajna cena",
|
||||||
|
"keyshop_price": "Cena v trgovini s ključki",
|
||||||
|
"historical_retail": "Zgodovinska maloprodajna cena",
|
||||||
|
"historical_keyshop": "Zgodovinska cena v trgovini s ključki",
|
||||||
|
"language": "Jezik",
|
||||||
|
"caption": "Naslov",
|
||||||
|
"audio": "Zvok",
|
||||||
|
"filter_by_source": "Filtriraj po viru",
|
||||||
|
"no_repacks_found": "Za to igro ni najdenih virov",
|
||||||
|
"delete_review": "Izbriši mnenje",
|
||||||
|
"remove_review": "Odstrani mnenje",
|
||||||
|
"delete_review_modal_title": "Ali ste prepričani, da želite izbrisati svoje mnenje?",
|
||||||
|
"delete_review_modal_description": "Tega dejanja ni mogoče razveljaviti.",
|
||||||
|
"delete_review_modal_delete_button": "Izbriši",
|
||||||
|
"delete_review_modal_cancel_button": "Prekliči",
|
||||||
|
"vote_failed": "Glasovanja ni uspelo. Prosimo, poskusite znova.",
|
||||||
|
"show_original": "Pokaži original",
|
||||||
|
"show_translation": "Pokaži prevod",
|
||||||
|
"show_original_translated_from": "Pokaži original (prevedeno iz {{language}})",
|
||||||
|
"hide_original": "Skrij original",
|
||||||
|
"review_from_blocked_user": "Mnenje blokiranega uporabnika",
|
||||||
|
"show": "Pokaži",
|
||||||
|
"hide": "Skrij"
|
||||||
|
},
|
||||||
|
"activation": {
|
||||||
|
"title": "Aktiviraj Hydra",
|
||||||
|
"installation_id": "ID namestitve:",
|
||||||
|
"enter_activation_code": "Vnesite aktivacijsko kodo",
|
||||||
|
"message": "Če ne veste, kje naj to pridobite, potem tega ne bi smeli imeti.",
|
||||||
|
"activate": "Aktiviraj",
|
||||||
|
"loading": "Nalaganje…"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"resume": "Nadaljuj",
|
||||||
|
"pause": "Premor",
|
||||||
|
"eta": "Zaključek {{eta}}",
|
||||||
|
"paused": "V premoru",
|
||||||
|
"verifying": "Preverjanje…",
|
||||||
|
"completed": "Dokončano",
|
||||||
|
"removed": "Ni preneseno",
|
||||||
|
"cancel": "Prekliči",
|
||||||
|
"cancel_download": "Prekliči prenos?",
|
||||||
|
"cancel_download_description": "Ali ste prepričani, da želite prekiniti ta prenos? Vse prenesene datoteke bodo izbrisane.",
|
||||||
|
"keep_downloading": "Ne, nadaljuj prenos",
|
||||||
|
"yes_cancel": "Da, prekliči",
|
||||||
|
"filter": "Filtriraj prenesene igre",
|
||||||
|
"remove": "Odstrani",
|
||||||
|
"downloading_metadata": "Prenos metapodatkov…",
|
||||||
|
"deleting": "Brisanje namestitvenega programa…",
|
||||||
|
"delete": "Odstrani namestitveni program",
|
||||||
|
"delete_modal_title": "Ali ste prepričani?",
|
||||||
|
"delete_modal_description": "To bo odstranilo vse namestitvene datoteke z računalnika",
|
||||||
|
"install": "Namesti",
|
||||||
|
"download_in_progress": "V teku",
|
||||||
|
"queued_downloads": "Prenosi v čakalni vrsti",
|
||||||
|
"downloads_completed": "Dokončano",
|
||||||
|
"queued": "V čakalni vrsti",
|
||||||
|
"no_downloads_title": "Tako prazno",
|
||||||
|
"no_downloads_description": "Še niste prenesli ničesar z Hydra, a nikoli ni prepozno začeti.",
|
||||||
|
"checking_files": "Preverjanje datotek…",
|
||||||
|
"seeding": "Sejanje",
|
||||||
|
"stop_seeding": "Ustavi sejanje",
|
||||||
|
"resume_seeding": "Nadaljuj sejanje",
|
||||||
|
"options": "Upravljaj",
|
||||||
|
"extract": "Razpakiraj datoteke",
|
||||||
|
"extracting": "Razpakiranje datotek…",
|
||||||
|
"delete_archive_title": "Ali želite izbrisati {{fileName}}?",
|
||||||
|
"delete_archive_description": "Datoteka je bila uspešno razpakirana in ni več potrebna.",
|
||||||
|
"yes": "Da",
|
||||||
|
"no": "Ne",
|
||||||
|
"network": "OMREŽJE",
|
||||||
|
"peak": "VRH"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"downloads_path": "Pot prenosa",
|
||||||
|
"change": "Posodobi",
|
||||||
|
"notifications": "Obvestila",
|
||||||
|
"enable_download_notifications": "Ko je prenos končan",
|
||||||
|
"enable_repack_list_notifications": "Ko je dodan nov repack",
|
||||||
|
"real_debrid_api_token_label": "Real-Debrid API žeton",
|
||||||
|
"quit_app_instead_hiding": "Ne skrij Hydre pri zapiranju",
|
||||||
|
"launch_with_system": "Zaženi Hydra ob zagonu sistema",
|
||||||
|
"general": "Splošno",
|
||||||
|
"behavior": "Obnašanje",
|
||||||
|
"download_sources": "Viri prenosa",
|
||||||
|
"language": "Jezik",
|
||||||
|
"api_token": "API žeton",
|
||||||
|
"enable_real_debrid": "Omogoči Real-Debrid",
|
||||||
|
"real_debrid_description": "Real-Debrid je neomejen prenašalnik, ki vam omogoča hitro prenašanje datotek, omejeno le s hitrostjo vašega interneta.",
|
||||||
|
"debrid_invalid_token": "Neveljaven API žeton",
|
||||||
|
"debrid_api_token_hint": "Žeton API lahko dobite <0>tukaj</0>",
|
||||||
|
"real_debrid_free_account_error": "Račun \"{{username}}\" je brezplačen. Prosimo, naročite se na Real-Debrid",
|
||||||
|
"debrid_linked_message": "Račun \"{{username}}\" povezan",
|
||||||
|
"save_changes": "Shrani spremembe",
|
||||||
|
"changes_saved": "Spremembe uspešno shranjene",
|
||||||
|
"download_sources_description": "Hydra bo pridobila povezave za prenos iz teh virov. URL vira mora biti neposredna povezava do .json datoteke, ki vsebuje povezave za prenos.",
|
||||||
|
"validate_download_source": "Preveri",
|
||||||
|
"remove_download_source": "Odstrani",
|
||||||
|
"add_download_source": "Dodaj vir",
|
||||||
|
"adding": "Dodajanje…",
|
||||||
|
"failed_add_download_source": "Dodajanje vira za prenos ni uspelo. Poskusite znova.",
|
||||||
|
"download_source_already_exists": "Ta URL vira za prenos že obstaja.",
|
||||||
|
"download_count_zero": "Ni možnosti prenosa",
|
||||||
|
"download_count_one": "{{countFormatted}} možnost prenosa",
|
||||||
|
"download_count_other": "{{countFormatted}} možnosti prenosa",
|
||||||
|
"download_source_url": "URL vira za prenos",
|
||||||
|
"add_download_source_description": "Vstavite URL .json datoteke",
|
||||||
|
"download_source_up_to_date": "Posodobljeno",
|
||||||
|
"download_source_errored": "Napaka",
|
||||||
|
"download_source_pending_matching": "Kmalu posodobljeno",
|
||||||
|
"download_source_matched": "Posodobljeno",
|
||||||
|
"download_source_matching": "Posodabljanje",
|
||||||
|
"download_source_failed": "Napaka",
|
||||||
|
"download_source_no_information": "Ni podatkov na voljo",
|
||||||
|
"sync_download_sources": "Sinhroniziraj vire",
|
||||||
|
"removed_download_source": "Vir prenosa odstranjen",
|
||||||
|
"removed_download_sources": "Viri prenosa odstranjeni",
|
||||||
|
"removed_all_download_sources": "Vsi viri prenosa odstranjeni",
|
||||||
|
"download_sources_synced_successfully": "Vsi viri prenosa so sinhronizirani",
|
||||||
|
"cancel_button_confirmation_delete_all_sources": "Ne",
|
||||||
|
"confirm_button_confirmation_delete_all_sources": "Da, izbriši vse",
|
||||||
|
"title_confirmation_delete_all_sources": "Izbriši vse vire prenosa",
|
||||||
|
"description_confirmation_delete_all_sources": "Izbrišete vse vire prenosa",
|
||||||
|
"button_delete_all_sources": "Odstrani vse",
|
||||||
|
"added_download_source": "Vir prenosa dodan",
|
||||||
|
"download_sources_synced": "Vsi viri prenosa so sinhronizirani",
|
||||||
|
"insert_valid_json_url": "Vnesite veljaven JSON URL",
|
||||||
|
"found_download_option_zero": "Ni možnosti prenosa",
|
||||||
|
"found_download_option_one": "Najdena {{countFormatted}} možnost prenosa",
|
||||||
|
"found_download_option_other": "Najdenih {{countFormatted}} možnosti prenosa",
|
||||||
|
"import": "Uvozi",
|
||||||
|
"importing": "Uvažanje...",
|
||||||
|
"public": "Javno",
|
||||||
|
"private": "Zasebno",
|
||||||
|
"friends_only": "Samo prijatelji",
|
||||||
|
"privacy": "Zasebnost",
|
||||||
|
"profile_visibility": "Vidnost profila",
|
||||||
|
"profile_visibility_description": "Izberite, kdo lahko vidi vaš profil in knjižnico",
|
||||||
|
"required_field": "To polje je obvezno",
|
||||||
|
"source_already_exists": "Ta vir je že bil dodan",
|
||||||
|
"must_be_valid_url": "Vir mora biti veljaven URL",
|
||||||
|
"blocked_users": "Blokirani uporabniki",
|
||||||
|
"user_unblocked": "Uporabnik je odblokiran",
|
||||||
|
"enable_achievement_notifications": "Ko je dosežek odklenjen",
|
||||||
|
"launch_minimized": "Zaženi Hydra minimizirano",
|
||||||
|
"disable_nsfw_alert": "Onemogoči opozorilo NSFW",
|
||||||
|
"seed_after_download_complete": "Sejanje po končanem prenosu",
|
||||||
|
"show_hidden_achievement_description": "Pokaži opis skritih dosežkov pred njihovim odklepanjem",
|
||||||
|
"account": "Račun",
|
||||||
|
"hydra_cloud": "Hydra Cloud",
|
||||||
|
"no_users_blocked": "Nimate blokiranih uporabnikov",
|
||||||
|
"subscription_active_until": "Vaš Hydra Cloud je aktiven do {{date}}",
|
||||||
|
"manage_subscription": "Upravljaj naročnino",
|
||||||
|
"update_email": "Posodobi e-pošto",
|
||||||
|
"update_password": "Posodobi geslo",
|
||||||
|
"current_email": "Trenutna e-pošta:",
|
||||||
|
"no_email_account": "Še niste nastavili e-pošte",
|
||||||
|
"account_data_updated_successfully": "Podatki računa so bili uspešno posodobljeni",
|
||||||
|
"renew_subscription": "Obnovi Hydra Cloud",
|
||||||
|
"subscription_expired_at": "Vaša naročnina je potekla {{date}}",
|
||||||
|
"no_subscription": "Uživajte v Hydri na najboljši način",
|
||||||
|
"become_subscriber": "Postanite Hydra Cloud uporabnik",
|
||||||
|
"subscription_renew_cancelled": "Samodejno podaljševanje je onemogočeno",
|
||||||
|
"subscription_renews_on": "Vaša naročnina se podaljša {{date}}",
|
||||||
|
"bill_sent_until": "Naslednji račun bo poslan do tega dne",
|
||||||
|
"no_themes": "Zdi se, da še nimate tem, vendar brez skrbi, kliknite tukaj, da ustvarite svojo prvo mojstrovino.",
|
||||||
|
"editor_tab_code": "Koda",
|
||||||
|
"editor_tab_info": "Info",
|
||||||
|
"editor_tab_save": "Shrani",
|
||||||
|
"web_store": "Spletna trgovina",
|
||||||
|
"clear_themes": "Počisti",
|
||||||
|
"create_theme": "Ustvari",
|
||||||
|
"create_theme_modal_title": "Ustvari prilagojeno temo",
|
||||||
|
"create_theme_modal_description": "Ustvarite novo temo za prilagajanje videza Hydre",
|
||||||
|
"theme_name": "Ime",
|
||||||
|
"insert_theme_name": "Vstavite ime teme",
|
||||||
|
"set_theme": "Nastavi temo",
|
||||||
|
"unset_theme": "Odstrani temo",
|
||||||
|
"delete_theme": "Izbriši temo",
|
||||||
|
"edit_theme": "Uredi temo",
|
||||||
|
"delete_all_themes": "Izbriši vse teme",
|
||||||
|
"delete_all_themes_description": "To bo izbrisalo vse vaše prilagojene teme",
|
||||||
|
"delete_theme_description": "To bo izbrisalo temo {{theme}}",
|
||||||
|
"cancel": "Prekliči",
|
||||||
|
"appearance": "Videz",
|
||||||
|
"debrid": "Debrid",
|
||||||
|
"debrid_description": "Debrid storitve so premium neomejeni prenašalniki, ki vam omogočajo hitro prenašanje datotek, gostovanih na različnih storitvah za gostovanje datotek, omejeno le s hitrostjo vašega interneta.",
|
||||||
|
"enable_torbox": "Omogoči TorBox",
|
||||||
|
"torbox_description": "TorBox je vaša premium seedbox storitev, ki se lahko kosuje tudi najboljšim strežnikom na trgu.",
|
||||||
|
"torbox_account_linked": "TorBox račun povezan",
|
||||||
|
"create_real_debrid_account": "Kliknite tukaj, če še nimate Real-Debrid računa",
|
||||||
|
"create_torbox_account": "Kliknite tukaj, če še nimate TorBox računa",
|
||||||
|
"real_debrid_account_linked": "Real-Debrid račun povezan",
|
||||||
|
"name_min_length": "Ime teme mora imeti vsaj 3 znake",
|
||||||
|
"import_theme": "Uvozi temo",
|
||||||
|
"import_theme_description": "Uvožili boste {{theme}} iz trgovine tem",
|
||||||
|
"error_importing_theme": "Napaka pri uvozu teme",
|
||||||
|
"theme_imported": "Tema uspešno uvožena",
|
||||||
|
"enable_friend_request_notifications": "Ko je prejet prijateljski zahtevek",
|
||||||
|
"enable_auto_install": "Samodejno prenesi posodobitve",
|
||||||
|
"common_redist": "Skupni redistributable-ji",
|
||||||
|
"common_redist_description": "Skupni redistributable-ji so potrebni za zagon nekaterih iger. Priporočamo njihovo namestitev, da se izognete težavam.",
|
||||||
|
"install_common_redist": "Namesti",
|
||||||
|
"installing_common_redist": "Nameščanje…",
|
||||||
|
"show_download_speed_in_megabytes": "Pokaži hitrost prenosa v megabajtih na sekundo",
|
||||||
|
"extract_files_by_default": "Privzeto razpakiraj datoteke po prenosu",
|
||||||
|
"enable_steam_achievements": "Omogoči iskanje po Steam dosežkih",
|
||||||
|
"enable_new_download_options_badges": "Pokaži značke novih možnosti prenosa",
|
||||||
|
"achievement_custom_notification_position": "Lastna pozicija obvestil o dosežkih",
|
||||||
|
"top-left": "Zgoraj levo",
|
||||||
|
"top-center": "Zgoraj na sredini",
|
||||||
|
"top-right": "Zgoraj desno",
|
||||||
|
"bottom-left": "Spodaj levo",
|
||||||
|
"bottom-center": "Spodaj na sredini",
|
||||||
|
"bottom-right": "Spodaj desno",
|
||||||
|
"enable_achievement_custom_notifications": "Omogoči lastna obvestila o dosežkih",
|
||||||
|
"alignment": "Poravnava",
|
||||||
|
"variation": "Variacija",
|
||||||
|
"default": "Privzeto",
|
||||||
|
"rare": "Redko",
|
||||||
|
"platinum": "Platinasto",
|
||||||
|
"hidden": "Skrito",
|
||||||
|
"test_notification": "Preizkusno obvestilo",
|
||||||
|
"achievement_sound_volume": "Glasnost zvoka dosežka",
|
||||||
|
"select_achievement_sound": "Izberite zvok dosežka",
|
||||||
|
"change_achievement_sound": "Spremeni zvok dosežka",
|
||||||
|
"remove_achievement_sound": "Odstrani zvok dosežka",
|
||||||
|
"preview_sound": "Predogled zvoka",
|
||||||
|
"select": "Izberi",
|
||||||
|
"preview": "Predogled",
|
||||||
|
"remove": "Odstrani",
|
||||||
|
"no_sound_file_selected": "Nobena zvočna datoteka ni izbrana",
|
||||||
|
"notification_preview": "Predogled obvestila o dosežku",
|
||||||
|
"enable_friend_start_game_notifications": "Ko prijatelj začne igrati igro",
|
||||||
|
"autoplay_trailers_on_game_page": "Samodejno predvajaj napovednike na strani igre",
|
||||||
|
"hide_to_tray_on_game_start": "Skrij Hydreo v sistemsko vrstico ob zagonu igre",
|
||||||
|
"downloads": "Prenosi",
|
||||||
|
"use_native_http_downloader": "Uporabi izvorni HTTP prenašalnik (eksperimentalno)",
|
||||||
|
"cannot_change_downloader_while_downloading": "Nastavitve ni mogoče spremeniti med prenosom",
|
||||||
|
"notifications": {
|
||||||
|
"download_complete": "Prenos končan",
|
||||||
|
"game_ready_to_install": "{{title}} je pripravljen za namestitev",
|
||||||
|
"repack_list_updated": "Seznam repackov posodobljen",
|
||||||
|
"repack_count_one": "{{count}} repack dodan",
|
||||||
|
"repack_count_other": "{{count}} repackov dodanih",
|
||||||
|
"new_update_available": "Različica {{version}} na voljo",
|
||||||
|
"restart_to_install_update": "Znova zaženite Hydreo za namestitev posodobitve",
|
||||||
|
"notification_achievement_unlocked_title": "Dosežek odklenjen za {{game}}",
|
||||||
|
"notification_achievement_unlocked_body": "{{achievement}} in drugi {{count}} so bili odklenjeni",
|
||||||
|
"new_friend_request_description": "{{displayName}} vam je poslal prijateljsko zahtevo",
|
||||||
|
"new_friend_request_title": "Nova prijateljska zahteva",
|
||||||
|
"extraction_complete": "Razpakiranje končano",
|
||||||
|
"game_extracted": "{{title}} je bil uspešno razpakiran",
|
||||||
|
"friend_started_playing_game": "{{displayName}} je začel igrati igro",
|
||||||
|
"test_achievement_notification_title": "To je preizkusno obvestilo",
|
||||||
|
"test_achievement_notification_description": "Kar kul, kajne?"
|
||||||
|
},
|
||||||
|
"system_tray": {
|
||||||
|
"open": "Odpri Hydreo",
|
||||||
|
"quit": "Izhod"
|
||||||
|
},
|
||||||
|
"game_card": {
|
||||||
|
"available_one": "Na voljo",
|
||||||
|
"available_other": "Na voljo",
|
||||||
|
"no_downloads": "Ni razpoložljivih prenosov",
|
||||||
|
"calculating": "Računam"
|
||||||
|
},
|
||||||
|
"binary_not_found_modal": {
|
||||||
|
"title": "Programi niso nameščeni",
|
||||||
|
"description": "Izvajalniki Wine ali Lutris niso bili najdeni na vašem sistemu",
|
||||||
|
"instructions": "Preverite pravi način za namestitev katerega od njih na vašo Linux distribucijo, da bi igra lahko normalno tekla"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"close": "Zapri gumb"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"toggle_password_visibility": "Preklopi vidnost gesla"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"amount_hours": "{{amount}} ur",
|
||||||
|
"amount_minutes": "{{amount}} minut",
|
||||||
|
"amount_hours_short": "{{amount}}h",
|
||||||
|
"amount_minutes_short": "{{amount}}m",
|
||||||
|
"last_time_played": "Nazadnje igrano {{period}}",
|
||||||
|
"activity": "Nedavna dejavnost",
|
||||||
|
"library": "Knjižnica",
|
||||||
|
"pinned": "Pripeto",
|
||||||
|
"sort_by": "Razvrsti po:",
|
||||||
|
"achievements_earned": "Odklenjeni dosežki",
|
||||||
|
"played_recently": "Nazadnje igrano",
|
||||||
|
"playtime": "Čas igranja",
|
||||||
|
"total_play_time": "Skupni čas igranja",
|
||||||
|
"manual_playtime_tooltip": "Ta čas igranja je bil ročno posodobljen",
|
||||||
|
"no_recent_activity_title": "Hmmm… nič tukaj",
|
||||||
|
"no_recent_activity_description": "Niste igrali nobene igre v zadnjem času. Čas je, da to spremenite!",
|
||||||
|
"display_name": "Prikazno ime",
|
||||||
|
"saving": "Shranjevanje",
|
||||||
|
"save": "Shrani",
|
||||||
|
"edit_profile": "Uredi profil",
|
||||||
|
"saved_successfully": "Uspešno shranjeno",
|
||||||
|
"try_again": "Prosimo, poskusite znova",
|
||||||
|
"sign_out_modal_title": "Ste prepričani?",
|
||||||
|
"cancel": "Prekliči",
|
||||||
|
"successfully_signed_out": "Uspešno odjavljeni",
|
||||||
|
"sign_out": "Odjavi se",
|
||||||
|
"playing_for": "Igra za {{amount}}",
|
||||||
|
"sign_out_modal_text": "Vaša knjižnica je povezana s trenutnim računom. Ob odjavi knjižnica ne bo več vidna, napredek pa se ne bo shranil. Nadaljujete z odjavo?",
|
||||||
|
"add_friends": "Dodaj prijatelje",
|
||||||
|
"add": "Dodaj",
|
||||||
|
"friend_code": "Koda prijatelja",
|
||||||
|
"see_profile": "Poglej profil",
|
||||||
|
"sending": "Pošiljanje",
|
||||||
|
"friend_request_sent": "Zahteva za prijateljstvo poslana",
|
||||||
|
"friends": "Prijatelji",
|
||||||
|
"badges": "Značke",
|
||||||
|
"friends_list": "Seznam prijateljev",
|
||||||
|
"user_not_found": "Uporabnik ni najden",
|
||||||
|
"block_user": "Blokiraj uporabnika",
|
||||||
|
"add_friend": "Dodaj prijatelja",
|
||||||
|
"request_sent": "Zahteva poslana",
|
||||||
|
"request_received": "Zahteva prejeta",
|
||||||
|
"accept_request": "Sprejmi zahtevo",
|
||||||
|
"ignore_request": "Ignoriraj zahtevo",
|
||||||
|
"cancel_request": "Prekliči zahtevo",
|
||||||
|
"undo_friendship": "Razveljavi prijateljstvo",
|
||||||
|
"friendship_removed": "Prijatelj odstranjen",
|
||||||
|
"request_accepted": "Zahteva sprejeta",
|
||||||
|
"user_blocked_successfully": "Uporabnik uspešno blokiran",
|
||||||
|
"user_block_modal_text": "To bo blokiralo {{displayName}}",
|
||||||
|
"blocked_users": "Blokirani uporabniki",
|
||||||
|
"unblock": "Odblokiraj",
|
||||||
|
"no_friends_added": "Nimate dodanih prijateljev",
|
||||||
|
"no_friends_yet": "Še niste dodali prijateljev",
|
||||||
|
"view_all": "Poglej vse",
|
||||||
|
"load_more": "Naloži več",
|
||||||
|
"loading": "Nalaganje",
|
||||||
|
"pending": "V teku",
|
||||||
|
"no_pending_invites": "Nimate čakajočih povabil",
|
||||||
|
"no_blocked_users": "Nimate blokiranih uporabnikov",
|
||||||
|
"friend_code_copied": "Koda prijatelja kopirana",
|
||||||
|
"undo_friendship_modal_text": "To bo razveljavilo vaše prijateljstvo z {{displayName}}",
|
||||||
|
"privacy_hint": "Za prilagoditev, kdo to vidi, pojdite na <0>Nastavitve</0>",
|
||||||
|
"locked_profile": "Ta profil je zaseben",
|
||||||
|
"image_process_failure": "Napaka pri obdelavi slike",
|
||||||
|
"required_field": "To polje je obvezno",
|
||||||
|
"displayname_min_length": "Prikazno ime mora biti dolgo vsaj 3 znake",
|
||||||
|
"displayname_max_length": "Prikazno ime mora imeti največ 50 znakov",
|
||||||
|
"report_profile": "Prijavi ta profil",
|
||||||
|
"report_reason": "Zakaj prijavljate ta profil?",
|
||||||
|
"report_description": "Dodatne informacije",
|
||||||
|
"report_description_placeholder": "Dodatne informacije",
|
||||||
|
"report": "Prijavi",
|
||||||
|
"report_reason_hate": "Sovražni govor",
|
||||||
|
"report_reason_sexual_content": "Seksualna vsebina",
|
||||||
|
"report_reason_violence": "Nasilje",
|
||||||
|
"report_reason_spam": "Spam",
|
||||||
|
"report_reason_other": "Drugo",
|
||||||
|
"profile_reported": "Profil prijavljen",
|
||||||
|
"your_friend_code": "Vaša koda prijatelja:",
|
||||||
|
"copy_friend_code": "Kopiraj kodo prijatelja",
|
||||||
|
"copied": "Kopirano!",
|
||||||
|
"upload_banner": "Naloži banner",
|
||||||
|
"uploading_banner": "Nalaganje bannerja…",
|
||||||
|
"change_banner": "Spremeni banner",
|
||||||
|
"replace_banner": "Zamenjaj banner",
|
||||||
|
"remove_banner": "Odstrani banner",
|
||||||
|
"remove_banner_modal_title": "Odstrani banner?",
|
||||||
|
"remove_banner_confirmation": "Ali ste prepričani, da želite odstraniti banner? Kadarkoli lahko izberete novega.",
|
||||||
|
"remove": "Odstrani",
|
||||||
|
"background_image_updated": "Pozadinska slika posodobljena",
|
||||||
|
"stats": "Statistika",
|
||||||
|
"achievements": "dosežki",
|
||||||
|
"games": "Igre",
|
||||||
|
"top_percentile": "Top {{percentile}}%",
|
||||||
|
"ranking_updated_weekly": "Uvrstitev se posodablja tedensko",
|
||||||
|
"playing": "Igra {{game}}",
|
||||||
|
"achievements_unlocked": "Dosežki odklenjeni",
|
||||||
|
"earned_points": "Zaslužene točke",
|
||||||
|
"show_achievements_on_profile": "Pokaži vaše dosežke na profilu",
|
||||||
|
"show_points_on_profile": "Pokaži vaše zaslužene točke na profilu",
|
||||||
|
"error_adding_friend": "Zahteve za prijatelja ni bilo mogoče poslati. Preverite kodo prijatelja",
|
||||||
|
"friend_code_length_error": "Koda prijatelja mora vsebovati 8 znakov",
|
||||||
|
"game_removed_from_pinned": "Igra odstranjena iz pripetih",
|
||||||
|
"game_added_to_pinned": "Igra dodana med pripete",
|
||||||
|
"karma": "Karma",
|
||||||
|
"karma_count": "karma",
|
||||||
|
"user_reviews": "Mnenja",
|
||||||
|
"delete_review": "Izbriši mnenje",
|
||||||
|
"loading_reviews": "Nalaganje mnenj...",
|
||||||
|
"wrapped_2025": "Wrapped 2025"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"library": "Knjižnica",
|
||||||
|
"play": "Igraj",
|
||||||
|
"download": "Prenesi",
|
||||||
|
"downloading": "Prenašanje",
|
||||||
|
"game": "igra",
|
||||||
|
"games": "igre",
|
||||||
|
"grid_view": "Mrežni pogled",
|
||||||
|
"compact_view": "Kompaktni pogled",
|
||||||
|
"large_view": "Velik pogled",
|
||||||
|
"no_games_title": "Vaša knjižnica je prazna",
|
||||||
|
"no_games_description": "Dodajte igre iz kataloga ali jih prenesite, da začnete",
|
||||||
|
"amount_hours": "{{amount}} ur",
|
||||||
|
"amount_minutes": "{{amount}} minut",
|
||||||
|
"amount_hours_short": "{{amount}}h",
|
||||||
|
"amount_minutes_short": "{{amount}}m",
|
||||||
|
"manual_playtime_tooltip": "Ta čas igranja je bil ročno posodobljen",
|
||||||
|
"all_games": "Vse igre",
|
||||||
|
"recently_played": "Nedavno igrane",
|
||||||
|
"favorites": "Priljubljene"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Dosežek odklenjen",
|
||||||
|
"user_achievements": "Dosežki uporabnika {{displayName}}",
|
||||||
|
"your_achievements": "Vaši dosežki",
|
||||||
|
"unlocked_at": "Odklenjeno: {{date}}",
|
||||||
|
"subscription_needed": "Naročnina na Hydra Cloud je potrebna za ogled te vsebine",
|
||||||
|
"new_achievements_unlocked": "Odklenili ste {{achievementCount}} novih dosežkov iz {{gameCount}} iger",
|
||||||
|
"achievement_progress": "{{unlockedCount}}/{{totalCount}} dosežkov",
|
||||||
|
"achievements_unlocked_for_game": "Odklenili ste {{achievementCount}} novih dosežkov za {{gameTitle}}",
|
||||||
|
"hidden_achievement_tooltip": "To je skriti dosežek",
|
||||||
|
"achievement_earn_points": "Z zaslužite {{points}} točk s tem dosežkom",
|
||||||
|
"earned_points": "Zaslužene točke:",
|
||||||
|
"available_points": "Razpoložljive točke:",
|
||||||
|
"how_to_earn_achievements_points": "Kako zaslužiti točke za dosežke?"
|
||||||
|
},
|
||||||
|
"hydra_cloud": {
|
||||||
|
"subscription_tour_title": "Naročnina Hydra Cloud",
|
||||||
|
"subscribe_now": "Naroči se zdaj",
|
||||||
|
"cloud_saving": "Shranjevanje v oblak",
|
||||||
|
"cloud_achievements": "Shrani svoje dosežke v oblak",
|
||||||
|
"animated_profile_picture": "Animirane profilne slike",
|
||||||
|
"premium_support": "Premium podpora",
|
||||||
|
"show_and_compare_achievements": "Pokaži in primerjaj svoje dosežke z drugimi uporabniki",
|
||||||
|
"animated_profile_banner": "Animirani profilni banner",
|
||||||
|
"hydra_cloud": "Hydra Cloud",
|
||||||
|
"hydra_cloud_feature_found": "Pravkar ste odkrili funkcijo Hydra Cloud!",
|
||||||
|
"learn_more": "Več informacij",
|
||||||
|
"debrid_description": "Prenesite do 4x hitreje z Nimbusom"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Obvestila",
|
||||||
|
"mark_all_as_read": "Označi vse kot prebrano",
|
||||||
|
"clear_all": "Počisti vse",
|
||||||
|
"loading": "Nalagam...",
|
||||||
|
"empty_title": "Ni obvestil",
|
||||||
|
"empty_description": "Ste na tekočem! Preverite kasneje za nove posodobitve.",
|
||||||
|
"empty_filter_description": "Nobeno obvestilo ne ustreza tem filtram.",
|
||||||
|
"filter_all": "Vse",
|
||||||
|
"filter_unread": "Neprebrano",
|
||||||
|
"filter_friends": "Prijatelji",
|
||||||
|
"filter_badges": "Značke",
|
||||||
|
"filter_upvotes": "Glasovi za všečkanje",
|
||||||
|
"filter_local": "Lokalno",
|
||||||
|
"load_more": "Naloži več",
|
||||||
|
"dismiss": "Opusti",
|
||||||
|
"accept": "Sprejmi",
|
||||||
|
"refuse": "Zavrni",
|
||||||
|
"notification": "Obvestilo",
|
||||||
|
"friend_request_received_title": "Nova prijateljska zahteva!",
|
||||||
|
"friend_request_received_description": "{{displayName}} želi biti vaš prijatelj",
|
||||||
|
"friend_request_accepted_title": "Zahteva za prijateljstvo sprejeta!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} je sprejel vašo zahtevo",
|
||||||
|
"badge_received_title": "Prejeli ste novo značko!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "Vaša recenzija za {{gameTitle}} je dobila glasove!",
|
||||||
|
"review_upvote_description": "Vaša recenzija je dobila {{count}} novih glasov",
|
||||||
|
"marked_all_as_read": "Vsa obvestila označena kot prebrana",
|
||||||
|
"failed_to_mark_as_read": "Neuspešno označevanje obvestil kot prebranih",
|
||||||
|
"cleared_all": "Vsa obvestila izbrisana",
|
||||||
|
"failed_to_clear": "Neuspešno brisanje obvestil",
|
||||||
|
"failed_to_load": "Neuspešno nalaganje obvestil",
|
||||||
|
"failed_to_dismiss": "Neuspešno opustitev obvestila",
|
||||||
|
"friend_request_accepted": "Zahteva za prijateljstvo sprejeta",
|
||||||
|
"friend_request_refused": "Zahteva za prijateljstvo zavrnjena"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -706,7 +706,6 @@
|
|||||||
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
|
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
|
|
||||||
"user_reviews": "İncelemeler",
|
"user_reviews": "İncelemeler",
|
||||||
"delete_review": "İncelemeyi Sil",
|
"delete_review": "İncelemeyi Sil",
|
||||||
"loading_reviews": "İncelemeler yükleniyor..."
|
"loading_reviews": "İncelemeler yükleniyor..."
|
||||||
|
|||||||
@@ -668,8 +668,7 @@
|
|||||||
"game_removed_from_pinned": "Гру видалено із закріплених",
|
"game_removed_from_pinned": "Гру видалено із закріплених",
|
||||||
"game_added_to_pinned": "Гру додано до закріплених",
|
"game_added_to_pinned": "Гру додано до закріплених",
|
||||||
"karma": "Карма",
|
"karma": "Карма",
|
||||||
"karma_count": "карма",
|
"karma_count": "карма"
|
||||||
"karma_description": "Зароблена позитивними оцінками на відгуках"
|
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Досягнення розблоковано",
|
"achievement_unlocked": "Досягнення розблоковано",
|
||||||
|
|||||||
@@ -689,7 +689,6 @@
|
|||||||
"game_removed_from_pinned": "游戏已从置顶移除",
|
"game_removed_from_pinned": "游戏已从置顶移除",
|
||||||
"karma": "业力",
|
"karma": "业力",
|
||||||
"karma_count": "业力值",
|
"karma_count": "业力值",
|
||||||
"karma_description": "通过评论获得的点赞",
|
|
||||||
"loading_reviews": "正在加载评价...",
|
"loading_reviews": "正在加载评价...",
|
||||||
"manual_playtime_tooltip": "该游戏时长已手动更新",
|
"manual_playtime_tooltip": "该游戏时长已手动更新",
|
||||||
"pinned": "已置顶",
|
"pinned": "已置顶",
|
||||||
|
|||||||
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { downloadsSublevel, levelKeys } from "@main/level";
|
||||||
|
import { GameShop } from "@types";
|
||||||
|
|
||||||
|
const getGameInstallerActionType = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string
|
||||||
|
): Promise<"install" | "open-folder"> => {
|
||||||
|
const downloadKey = levelKeys.game(shop, objectId);
|
||||||
|
const download = await downloadsSublevel.get(downloadKey);
|
||||||
|
|
||||||
|
if (!download?.folderName) return "open-folder";
|
||||||
|
|
||||||
|
const gamePath = path.join(
|
||||||
|
download.downloadPath ?? (await getDownloadsPath()),
|
||||||
|
download.folderName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(gamePath)) {
|
||||||
|
await downloadsSublevel.del(downloadKey);
|
||||||
|
return "open-folder";
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS always opens folder
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return "open-folder";
|
||||||
|
}
|
||||||
|
|
||||||
|
// If path is a file, it will show in folder (open-folder behavior)
|
||||||
|
if (fs.lstatSync(gamePath).isFile()) {
|
||||||
|
return "open-folder";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for setup.exe
|
||||||
|
const setupPath = path.join(gamePath, "setup.exe");
|
||||||
|
if (fs.existsSync(setupPath)) {
|
||||||
|
return "install";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's exactly one .exe file
|
||||||
|
const gamePathFileNames = fs.readdirSync(gamePath);
|
||||||
|
const gamePathExecutableFiles = gamePathFileNames.filter(
|
||||||
|
(fileName: string) => path.extname(fileName).toLowerCase() === ".exe"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gamePathExecutableFiles.length === 1) {
|
||||||
|
return "install";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, opens folder
|
||||||
|
return "open-folder";
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getGameInstallerActionType", getGameInstallerActionType);
|
||||||
@@ -25,7 +25,7 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
|||||||
const achievements = await gameAchievementsSublevel.get(key);
|
const achievements = await gameAchievementsSublevel.get(key);
|
||||||
|
|
||||||
unlockedAchievementCount =
|
unlockedAchievementCount =
|
||||||
achievements?.unlockedAchievements.length ?? 0;
|
achievements?.unlockedAchievements?.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import "./delete-game-folder";
|
|||||||
import "./extract-game-download";
|
import "./extract-game-download";
|
||||||
import "./get-default-wine-prefix-selection-path";
|
import "./get-default-wine-prefix-selection-path";
|
||||||
import "./get-game-by-object-id";
|
import "./get-game-by-object-id";
|
||||||
|
import "./get-game-installer-action-type";
|
||||||
import "./get-library";
|
import "./get-library";
|
||||||
import "./open-game-executable-path";
|
import "./open-game-executable-path";
|
||||||
import "./open-game-installer-path";
|
import "./open-game-installer-path";
|
||||||
@@ -23,6 +24,7 @@ import "./remove-game-from-favorites";
|
|||||||
import "./remove-game-from-library";
|
import "./remove-game-from-library";
|
||||||
import "./remove-game";
|
import "./remove-game";
|
||||||
import "./reset-game-achievements";
|
import "./reset-game-achievements";
|
||||||
|
import "./scan-installed-games";
|
||||||
import "./select-game-wine-prefix";
|
import "./select-game-wine-prefix";
|
||||||
import "./toggle-automatic-cloud-sync";
|
import "./toggle-automatic-cloud-sync";
|
||||||
import "./toggle-game-pin";
|
import "./toggle-game-pin";
|
||||||
|
|||||||
143
src/main/events/library/scan-installed-games.ts
Normal file
143
src/main/events/library/scan-installed-games.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { gamesSublevel } from "@main/level";
|
||||||
|
import {
|
||||||
|
GameExecutables,
|
||||||
|
LocalNotificationManager,
|
||||||
|
logger,
|
||||||
|
WindowManager,
|
||||||
|
} from "@main/services";
|
||||||
|
|
||||||
|
const SCAN_DIRECTORIES = [
|
||||||
|
String.raw`C:\Games`,
|
||||||
|
String.raw`D:\Games`,
|
||||||
|
String.raw`C:\Program Files (x86)\Steam\steamapps\common`,
|
||||||
|
String.raw`C:\Program Files\Steam\steamapps\common`,
|
||||||
|
String.raw`C:\Program Files (x86)\DODI-Repacks`,
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FoundGame {
|
||||||
|
title: string;
|
||||||
|
executablePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
foundGames: FoundGame[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchInDirectories(
|
||||||
|
executableNames: Set<string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
for (const scanDir of SCAN_DIRECTORIES) {
|
||||||
|
if (!fs.existsSync(scanDir)) continue;
|
||||||
|
|
||||||
|
const foundPath = await findExecutableInFolder(scanDir, executableNames);
|
||||||
|
if (foundPath) return foundPath;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishScanNotification(foundCount: number): Promise<void> {
|
||||||
|
const hasFoundGames = foundCount > 0;
|
||||||
|
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"SCAN_GAMES_COMPLETE",
|
||||||
|
t(
|
||||||
|
hasFoundGames
|
||||||
|
? "scan_games_complete_title"
|
||||||
|
: "scan_games_no_results_title",
|
||||||
|
{ ns: "notifications" }
|
||||||
|
),
|
||||||
|
t(
|
||||||
|
hasFoundGames
|
||||||
|
? "scan_games_complete_description"
|
||||||
|
: "scan_games_no_results_description",
|
||||||
|
{ ns: "notifications", count: foundCount }
|
||||||
|
),
|
||||||
|
{ url: "/library?openScanModal=true" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanInstalledGames = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent
|
||||||
|
): Promise<ScanResult> => {
|
||||||
|
const games = await gamesSublevel
|
||||||
|
.iterator()
|
||||||
|
.all()
|
||||||
|
.then((results) =>
|
||||||
|
results
|
||||||
|
.filter(
|
||||||
|
([_key, game]) => game.isDeleted === false && game.shop !== "custom"
|
||||||
|
)
|
||||||
|
.map(([key, game]) => ({ key, game }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundGames: FoundGame[] = [];
|
||||||
|
const gamesToScan = games.filter((g) => !g.game.executablePath);
|
||||||
|
|
||||||
|
for (const { key, game } of gamesToScan) {
|
||||||
|
const executableNames = GameExecutables.getExecutablesForGame(
|
||||||
|
game.objectId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!executableNames || executableNames.length === 0) continue;
|
||||||
|
|
||||||
|
const normalizedNames = new Set(
|
||||||
|
executableNames.map((name) => name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const foundPath = await searchInDirectories(normalizedNames);
|
||||||
|
|
||||||
|
if (foundPath) {
|
||||||
|
await gamesSublevel.put(key, { ...game, executablePath: foundPath });
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[ScanInstalledGames] Found executable for ${game.objectId}: ${foundPath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
foundGames.push({ title: game.title, executablePath: foundPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||||
|
await publishScanNotification(foundGames.length);
|
||||||
|
|
||||||
|
return { foundGames, total: gamesToScan.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findExecutableInFolder(
|
||||||
|
folderPath: string,
|
||||||
|
executableNames: Set<string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(folderPath, {
|
||||||
|
withFileTypes: true,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
|
||||||
|
const fileName = entry.name.toLowerCase();
|
||||||
|
|
||||||
|
if (executableNames.has(fileName)) {
|
||||||
|
const parentPath =
|
||||||
|
"parentPath" in entry ? entry.parentPath : folderPath;
|
||||||
|
|
||||||
|
return path.join(parentPath, entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`[ScanInstalledGames] Error reading folder ${folderPath}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEvent("scanInstalledGames", scanInstalledGames);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const clearAllLocalNotifications = async () => {
|
||||||
|
await LocalNotificationManager.clearAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);
|
||||||
11
src/main/events/notifications/delete-local-notification.ts
Normal file
11
src/main/events/notifications/delete-local-notification.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const deleteLocalNotification = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
await LocalNotificationManager.deleteNotification(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("deleteLocalNotification", deleteLocalNotification);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const getLocalNotificationsCount = async () => {
|
||||||
|
return LocalNotificationManager.getUnreadCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);
|
||||||
8
src/main/events/notifications/get-local-notifications.ts
Normal file
8
src/main/events/notifications/get-local-notifications.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const getLocalNotifications = async () => {
|
||||||
|
return LocalNotificationManager.getNotifications();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getLocalNotifications", getLocalNotifications);
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import "./publish-new-repacks-notification";
|
import "./publish-new-repacks-notification";
|
||||||
import "./show-achievement-test-notification";
|
import "./show-achievement-test-notification";
|
||||||
import "./update-achievement-notification-window";
|
import "./update-achievement-notification-window";
|
||||||
|
import "./get-local-notifications";
|
||||||
|
import "./get-local-notifications-count";
|
||||||
|
import "./mark-local-notification-read";
|
||||||
|
import "./mark-all-local-notifications-read";
|
||||||
|
import "./delete-local-notification";
|
||||||
|
import "./clear-all-local-notifications";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const markAllLocalNotificationsRead = async () => {
|
||||||
|
await LocalNotificationManager.markAllAsRead();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const markLocalNotificationRead = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
await LocalNotificationManager.markAsRead(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("markLocalNotificationRead", markLocalNotificationRead);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import "./get-me";
|
import "./get-me";
|
||||||
import "./process-profile-image";
|
import "./process-profile-image";
|
||||||
import "./sync-friend-requests";
|
|
||||||
import "./update-profile";
|
import "./update-profile";
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { registerEvent } from "../register-event";
|
|
||||||
import { HydraApi, WindowManager } from "@main/services";
|
|
||||||
import { UserNotLoggedInError } from "@shared";
|
|
||||||
import type { FriendRequestSync } from "@types";
|
|
||||||
|
|
||||||
export const syncFriendRequests = async () => {
|
|
||||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
|
|
||||||
.then((res) => {
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
|
||||||
"on-sync-friend-requests",
|
|
||||||
res
|
|
||||||
);
|
|
||||||
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err instanceof UserNotLoggedInError) {
|
|
||||||
return { friendRequestCount: 0 } as FriendRequestSync;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
registerEvent("syncFriendRequests", syncFriendRequests);
|
|
||||||
@@ -51,7 +51,10 @@ const updateProfile = async (
|
|||||||
"backgroundImageUrl",
|
"backgroundImageUrl",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (updateProfile.profileImageUrl) {
|
if (updateProfile.profileImageUrl !== undefined) {
|
||||||
|
if (updateProfile.profileImageUrl === null) {
|
||||||
|
payload["profileImageUrl"] = null;
|
||||||
|
} else {
|
||||||
const profileImageUrl = await uploadImage(
|
const profileImageUrl = await uploadImage(
|
||||||
"profile-image",
|
"profile-image",
|
||||||
updateProfile.profileImageUrl
|
updateProfile.profileImageUrl
|
||||||
@@ -59,8 +62,12 @@ const updateProfile = async (
|
|||||||
|
|
||||||
payload["profileImageUrl"] = profileImageUrl;
|
payload["profileImageUrl"] = profileImageUrl;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updateProfile.backgroundImageUrl) {
|
if (updateProfile.backgroundImageUrl !== undefined) {
|
||||||
|
if (updateProfile.backgroundImageUrl === null) {
|
||||||
|
payload["backgroundImageUrl"] = null;
|
||||||
|
} else {
|
||||||
const backgroundImageUrl = await uploadImage(
|
const backgroundImageUrl = await uploadImage(
|
||||||
"background-image",
|
"background-image",
|
||||||
updateProfile.backgroundImageUrl
|
updateProfile.backgroundImageUrl
|
||||||
@@ -68,6 +75,7 @@ const updateProfile = async (
|
|||||||
|
|
||||||
payload["backgroundImageUrl"] = backgroundImageUrl;
|
payload["backgroundImageUrl"] = backgroundImageUrl;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return patchUserProfile(payload);
|
return patchUserProfile(payload);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const startGameDownload = async (
|
|||||||
const game = await gamesSublevel.get(gameKey);
|
const game = await gamesSublevel.get(gameKey);
|
||||||
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||||
|
|
||||||
/* Delete any previous download */
|
|
||||||
await downloadsSublevel.del(gameKey);
|
await downloadsSublevel.del(gameKey);
|
||||||
|
|
||||||
if (game) {
|
if (game) {
|
||||||
@@ -124,6 +123,42 @@ const startGameDownload = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
if (downloader === Downloader.Buzzheavier) {
|
||||||
|
if (err.message.includes("Rate limit")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Buzzheavier: Rate limit exceeded",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err.message.includes("not found") ||
|
||||||
|
err.message.includes("deleted")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Buzzheavier: File not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloader === Downloader.FuckingFast) {
|
||||||
|
if (err.message.includes("Rate limit")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "FuckingFast: Rate limit exceeded",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err.message.includes("not found") ||
|
||||||
|
err.message.includes("deleted")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "FuckingFast: File not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: false, error: err.message };
|
return { ok: false, error: err.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @generated by protobuf-ts 2.10.0
|
// @generated by protobuf-ts 2.11.1
|
||||||
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||||
// tslint:disable
|
// tslint:disable
|
||||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||||
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
|
|||||||
*/
|
*/
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
* @generated from protobuf field: int32 friend_request_count = 1
|
||||||
*/
|
*/
|
||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: optional string sender_id = 2;
|
* @generated from protobuf field: optional string sender_id = 2
|
||||||
*/
|
*/
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
}
|
}
|
||||||
@@ -28,18 +28,27 @@ export interface FriendRequest {
|
|||||||
*/
|
*/
|
||||||
export interface FriendGameSession {
|
export interface FriendGameSession {
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string object_id = 1;
|
* @generated from protobuf field: string object_id = 1
|
||||||
*/
|
*/
|
||||||
objectId: string;
|
objectId: string;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string shop = 2;
|
* @generated from protobuf field: string shop = 2
|
||||||
*/
|
*/
|
||||||
shop: string;
|
shop: string;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string friend_id = 3;
|
* @generated from protobuf field: string friend_id = 3
|
||||||
*/
|
*/
|
||||||
friendId: string;
|
friendId: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @generated from protobuf message Notification
|
||||||
|
*/
|
||||||
|
export interface Notification {
|
||||||
|
/**
|
||||||
|
* @generated from protobuf field: int32 notification_count = 1
|
||||||
|
*/
|
||||||
|
notificationCount: number;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf message Envelope
|
* @generated from protobuf message Envelope
|
||||||
*/
|
*/
|
||||||
@@ -51,17 +60,24 @@ export interface Envelope {
|
|||||||
| {
|
| {
|
||||||
oneofKind: "friendRequest";
|
oneofKind: "friendRequest";
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
* @generated from protobuf field: FriendRequest friend_request = 1
|
||||||
*/
|
*/
|
||||||
friendRequest: FriendRequest;
|
friendRequest: FriendRequest;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
oneofKind: "friendGameSession";
|
oneofKind: "friendGameSession";
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
* @generated from protobuf field: FriendGameSession friend_game_session = 2
|
||||||
*/
|
*/
|
||||||
friendGameSession: FriendGameSession;
|
friendGameSession: FriendGameSession;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
oneofKind: "notification";
|
||||||
|
/**
|
||||||
|
* @generated from protobuf field: Notification notification = 3
|
||||||
|
*/
|
||||||
|
notification: Notification;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
oneofKind: undefined;
|
oneofKind: undefined;
|
||||||
};
|
};
|
||||||
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
|||||||
*/
|
*/
|
||||||
export const FriendGameSession = new FriendGameSession$Type();
|
export const FriendGameSession = new FriendGameSession$Type();
|
||||||
// @generated message type with reflection information, may provide speed optimized methods
|
// @generated message type with reflection information, may provide speed optimized methods
|
||||||
|
class Notification$Type extends MessageType<Notification> {
|
||||||
|
constructor() {
|
||||||
|
super("Notification", [
|
||||||
|
{
|
||||||
|
no: 1,
|
||||||
|
name: "notification_count",
|
||||||
|
kind: "scalar",
|
||||||
|
T: 5 /*ScalarType.INT32*/,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
create(value?: PartialMessage<Notification>): Notification {
|
||||||
|
const message = globalThis.Object.create(this.messagePrototype!);
|
||||||
|
message.notificationCount = 0;
|
||||||
|
if (value !== undefined)
|
||||||
|
reflectionMergePartial<Notification>(this, message, value);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
internalBinaryRead(
|
||||||
|
reader: IBinaryReader,
|
||||||
|
length: number,
|
||||||
|
options: BinaryReadOptions,
|
||||||
|
target?: Notification
|
||||||
|
): Notification {
|
||||||
|
let message = target ?? this.create(),
|
||||||
|
end = reader.pos + length;
|
||||||
|
while (reader.pos < end) {
|
||||||
|
let [fieldNo, wireType] = reader.tag();
|
||||||
|
switch (fieldNo) {
|
||||||
|
case /* int32 notification_count */ 1:
|
||||||
|
message.notificationCount = reader.int32();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
let u = options.readUnknownField;
|
||||||
|
if (u === "throw")
|
||||||
|
throw new globalThis.Error(
|
||||||
|
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||||
|
);
|
||||||
|
let d = reader.skip(wireType);
|
||||||
|
if (u !== false)
|
||||||
|
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||||
|
this.typeName,
|
||||||
|
message,
|
||||||
|
fieldNo,
|
||||||
|
wireType,
|
||||||
|
d
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
internalBinaryWrite(
|
||||||
|
message: Notification,
|
||||||
|
writer: IBinaryWriter,
|
||||||
|
options: BinaryWriteOptions
|
||||||
|
): IBinaryWriter {
|
||||||
|
/* int32 notification_count = 1; */
|
||||||
|
if (message.notificationCount !== 0)
|
||||||
|
writer.tag(1, WireType.Varint).int32(message.notificationCount);
|
||||||
|
let u = options.writeUnknownFields;
|
||||||
|
if (u !== false)
|
||||||
|
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||||
|
this.typeName,
|
||||||
|
message,
|
||||||
|
writer
|
||||||
|
);
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @generated MessageType for protobuf message Notification
|
||||||
|
*/
|
||||||
|
export const Notification = new Notification$Type();
|
||||||
|
// @generated message type with reflection information, may provide speed optimized methods
|
||||||
class Envelope$Type extends MessageType<Envelope> {
|
class Envelope$Type extends MessageType<Envelope> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Envelope", [
|
super("Envelope", [
|
||||||
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
oneof: "payload",
|
oneof: "payload",
|
||||||
T: () => FriendGameSession,
|
T: () => FriendGameSession,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
no: 3,
|
||||||
|
name: "notification",
|
||||||
|
kind: "message",
|
||||||
|
oneof: "payload",
|
||||||
|
T: () => Notification,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
create(value?: PartialMessage<Envelope>): Envelope {
|
create(value?: PartialMessage<Envelope>): Envelope {
|
||||||
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case /* Notification notification */ 3:
|
||||||
|
message.payload = {
|
||||||
|
oneofKind: "notification",
|
||||||
|
notification: Notification.internalBinaryRead(
|
||||||
|
reader,
|
||||||
|
reader.uint32(),
|
||||||
|
options,
|
||||||
|
(message.payload as any).notification
|
||||||
|
),
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
let u = options.readUnknownField;
|
let u = options.readUnknownField;
|
||||||
if (u === "throw")
|
if (u === "throw")
|
||||||
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||||
options
|
options
|
||||||
).join();
|
).join();
|
||||||
|
/* Notification notification = 3; */
|
||||||
|
if (message.payload.oneofKind === "notification")
|
||||||
|
Notification.internalBinaryWrite(
|
||||||
|
message.payload.notification,
|
||||||
|
writer.tag(3, WireType.LengthDelimited).fork(),
|
||||||
|
options
|
||||||
|
).join();
|
||||||
let u = options.writeUnknownFields;
|
let u = options.writeUnknownFields;
|
||||||
if (u !== false)
|
if (u !== false)
|
||||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
|||||||
export * from "./themes";
|
export * from "./themes";
|
||||||
export * from "./download-sources";
|
export * from "./download-sources";
|
||||||
export * from "./downloadSourcesCheckTimestamp";
|
export * from "./downloadSourcesCheckTimestamp";
|
||||||
|
export * from "./local-notifications";
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export const levelKeys = {
|
|||||||
downloadSources: "downloadSources",
|
downloadSources: "downloadSources",
|
||||||
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||||
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
||||||
|
localNotifications: "localNotifications",
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/main/level/sublevels/local-notifications.ts
Normal file
11
src/main/level/sublevels/local-notifications.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LocalNotification } from "@types";
|
||||||
|
|
||||||
|
import { db } from "../level";
|
||||||
|
import { levelKeys } from "./keys";
|
||||||
|
|
||||||
|
export const localNotificationsSublevel = db.sublevel<
|
||||||
|
string,
|
||||||
|
LocalNotification
|
||||||
|
>(levelKeys.localNotifications, {
|
||||||
|
valueEncoding: "json",
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { downloadsSublevel } from "./level/sublevels/downloads";
|
|||||||
import { orderBy } from "lodash-es";
|
import { orderBy } from "lodash-es";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import { levelKeys, db } from "./level";
|
import { levelKeys, db } from "./level";
|
||||||
import type { UserPreferences } from "@types";
|
import type { Download, UserPreferences } from "@types";
|
||||||
import {
|
import {
|
||||||
SystemPath,
|
SystemPath,
|
||||||
CommonRedistManager,
|
CommonRedistManager,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DeckyPlugin,
|
DeckyPlugin,
|
||||||
DownloadSourcesChecker,
|
DownloadSourcesChecker,
|
||||||
WSClient,
|
WSClient,
|
||||||
|
logger,
|
||||||
} from "@main/services";
|
} from "@main/services";
|
||||||
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
||||||
|
|
||||||
@@ -57,8 +58,10 @@ export const loadState = async () => {
|
|||||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||||
void syncDownloadSourcesFromApi();
|
void syncDownloadSourcesFromApi();
|
||||||
|
|
||||||
// Check for new download options on startup
|
// Check for new download options on startup (if enabled)
|
||||||
DownloadSourcesChecker.checkForChanges();
|
(async () => {
|
||||||
|
await DownloadSourcesChecker.checkForChanges();
|
||||||
|
})();
|
||||||
WSClient.connect();
|
WSClient.connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,18 +72,47 @@ export const loadState = async () => {
|
|||||||
return orderBy(games, "timestamp", "desc");
|
return orderBy(games, "timestamp", "desc");
|
||||||
});
|
});
|
||||||
|
|
||||||
downloads.forEach((download) => {
|
let interruptedDownload: Download | null = null;
|
||||||
|
|
||||||
|
for (const download of downloads) {
|
||||||
|
const downloadKey = levelKeys.game(download.shop, download.objectId);
|
||||||
|
|
||||||
|
// Reset extracting state
|
||||||
if (download.extracting) {
|
if (download.extracting) {
|
||||||
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), {
|
await downloadsSublevel.put(downloadKey, {
|
||||||
...download,
|
...download,
|
||||||
extracting: false,
|
extracting: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find interrupted active download (download that was running when app closed)
|
||||||
|
// Mark it as paused but remember it for auto-resume
|
||||||
|
if (download.status === "active" && !interruptedDownload) {
|
||||||
|
interruptedDownload = download;
|
||||||
|
await downloadsSublevel.put(downloadKey, {
|
||||||
|
...download,
|
||||||
|
status: "paused",
|
||||||
});
|
});
|
||||||
|
} else if (download.status === "active") {
|
||||||
|
// Mark other active downloads as paused
|
||||||
|
await downloadsSublevel.put(downloadKey, {
|
||||||
|
...download,
|
||||||
|
status: "paused",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
|
// Re-fetch downloads after status updates
|
||||||
|
const updatedDownloads = await downloadsSublevel
|
||||||
|
.values()
|
||||||
|
.all()
|
||||||
|
.then((games) => orderBy(games, "timestamp", "desc"));
|
||||||
|
|
||||||
const downloadsToSeed = downloads.filter(
|
// Prioritize interrupted download, then queued downloads
|
||||||
|
const downloadToResume =
|
||||||
|
interruptedDownload ?? updatedDownloads.find((game) => game.queued);
|
||||||
|
|
||||||
|
const downloadsToSeed = updatedDownloads.filter(
|
||||||
(game) =>
|
(game) =>
|
||||||
game.shouldSeed &&
|
game.shouldSeed &&
|
||||||
game.downloader === Downloader.Torrent &&
|
game.downloader === Downloader.Torrent &&
|
||||||
@@ -88,7 +120,23 @@ export const loadState = async () => {
|
|||||||
game.uri !== null
|
game.uri !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
|
// For torrents or if JS downloader is disabled, use Python RPC
|
||||||
|
const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
|
||||||
|
// Default to true - native HTTP downloader is enabled by default
|
||||||
|
const useJsDownloader =
|
||||||
|
(userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
|
||||||
|
|
||||||
|
if (useJsDownloader && downloadToResume) {
|
||||||
|
// Start Python RPC for seeding only, then resume HTTP download with JS
|
||||||
|
await DownloadManager.startRPC(undefined, downloadsToSeed);
|
||||||
|
await DownloadManager.startDownload(downloadToResume).catch((err) => {
|
||||||
|
// If resume fails, just log it - user can manually retry
|
||||||
|
logger.error("Failed to auto-resume download:", err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use Python RPC for everything (torrent or fallback)
|
||||||
|
await DownloadManager.startRPC(downloadToResume, downloadsToSeed);
|
||||||
|
}
|
||||||
|
|
||||||
startMainLoop();
|
startMainLoop();
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class SevenZip {
|
|||||||
onProgress?: (progress: ExtractionProgress) => void
|
onProgress?: (progress: ExtractionProgress) => void
|
||||||
): Promise<ExtractionResult> {
|
): Promise<ExtractionResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tryPassword = (index = -1) => {
|
const tryPassword = (index = 0) => {
|
||||||
const password = passwords[index] ?? "";
|
const password = passwords[index] ?? "";
|
||||||
logger.info(
|
logger.info(
|
||||||
`Trying password "${password || "(empty)"}" on ${filePath}`
|
`Trying password "${password || "(empty)"}" on ${filePath}`
|
||||||
@@ -115,7 +115,7 @@ export class SevenZip {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
tryPassword();
|
tryPassword(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
updateDownloadSourcesCheckBaseline,
|
updateDownloadSourcesCheckBaseline,
|
||||||
updateDownloadSourcesSinceValue,
|
updateDownloadSourcesSinceValue,
|
||||||
downloadSourcesSublevel,
|
downloadSourcesSublevel,
|
||||||
|
db,
|
||||||
|
levelKeys,
|
||||||
} from "@main/level";
|
} from "@main/level";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import type { Game } from "@types";
|
import type { Game, UserPreferences } from "@types";
|
||||||
|
|
||||||
interface DownloadSourcesChangeResponse {
|
interface DownloadSourcesChangeResponse {
|
||||||
shop: string;
|
shop: string;
|
||||||
@@ -101,6 +103,20 @@ export class DownloadSourcesChecker {
|
|||||||
logger.info("DownloadSourcesChecker.checkForChanges() called");
|
logger.info("DownloadSourcesChecker.checkForChanges() called");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
|
levelKeys.userPreferences,
|
||||||
|
{
|
||||||
|
valueEncoding: "json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userPreferences?.enableNewDownloadOptionsBadges === false) {
|
||||||
|
logger.info(
|
||||||
|
"New download options badges are disabled, skipping download sources check"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all installed games (excluding custom games)
|
// Get all installed games (excluding custom games)
|
||||||
const installedGames = await gamesSublevel.values().all();
|
const installedGames = await gamesSublevel.values().all();
|
||||||
const nonCustomGames = installedGames.filter(
|
const nonCustomGames = installedGames.filter(
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
|
|||||||
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
||||||
import {
|
import {
|
||||||
GofileApi,
|
GofileApi,
|
||||||
QiwiApi,
|
|
||||||
DatanodesApi,
|
DatanodesApi,
|
||||||
MediafireApi,
|
MediafireApi,
|
||||||
PixelDrainApi,
|
PixelDrainApi,
|
||||||
|
VikingFileApi,
|
||||||
|
RootzApi,
|
||||||
} from "../hosters";
|
} from "../hosters";
|
||||||
import { PythonRPC } from "../python-rpc";
|
import { PythonRPC } from "../python-rpc";
|
||||||
import {
|
import {
|
||||||
@@ -17,16 +18,131 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import { calculateETA, getDirSize } from "./helpers";
|
import { calculateETA, getDirSize } from "./helpers";
|
||||||
import { RealDebridClient } from "./real-debrid";
|
import { RealDebridClient } from "./real-debrid";
|
||||||
import path from "path";
|
import path from "node:path";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||||
import { orderBy } from "lodash-es";
|
import { sortBy } from "lodash-es";
|
||||||
import { TorBoxClient } from "./torbox";
|
import { TorBoxClient } from "./torbox";
|
||||||
import { GameFilesManager } from "../game-files-manager";
|
import { GameFilesManager } from "../game-files-manager";
|
||||||
import { HydraDebridClient } from "./hydra-debrid";
|
import { HydraDebridClient } from "./hydra-debrid";
|
||||||
|
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
||||||
|
import { JsHttpDownloader } from "./js-http-downloader";
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static downloadingGameId: string | null = null;
|
private static downloadingGameId: string | null = null;
|
||||||
|
private static jsDownloader: JsHttpDownloader | null = null;
|
||||||
|
private static usingJsDownloader = false;
|
||||||
|
private static isPreparingDownload = false;
|
||||||
|
|
||||||
|
private static extractFilename(
|
||||||
|
url: string,
|
||||||
|
originalUrl?: string
|
||||||
|
): string | undefined {
|
||||||
|
if (originalUrl?.includes("#")) {
|
||||||
|
const hashPart = originalUrl.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||||
|
return hashPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("#")) {
|
||||||
|
const hashPart = url.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||||
|
return hashPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
const pathParts = pathname.split("/");
|
||||||
|
const filename = pathParts.at(-1);
|
||||||
|
|
||||||
|
if (filename?.includes(".") && filename.length > 0) {
|
||||||
|
return decodeURIComponent(filename);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sanitizeFilename(filename: string): string {
|
||||||
|
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static resolveFilename(
|
||||||
|
resumingFilename: string | undefined,
|
||||||
|
originalUrl: string,
|
||||||
|
downloadUrl: string
|
||||||
|
): string | undefined {
|
||||||
|
if (resumingFilename) return resumingFilename;
|
||||||
|
|
||||||
|
const extracted =
|
||||||
|
this.extractFilename(originalUrl, downloadUrl) ||
|
||||||
|
this.extractFilename(downloadUrl);
|
||||||
|
|
||||||
|
return extracted ? this.sanitizeFilename(extracted) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buildDownloadOptions(
|
||||||
|
url: string,
|
||||||
|
savePath: string,
|
||||||
|
filename: string | undefined,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
savePath,
|
||||||
|
filename,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createDownloadPayload(
|
||||||
|
directUrl: string,
|
||||||
|
originalUrl: string,
|
||||||
|
downloadId: string,
|
||||||
|
savePath: string
|
||||||
|
) {
|
||||||
|
const filename =
|
||||||
|
this.extractFilename(originalUrl, directUrl) ||
|
||||||
|
this.extractFilename(directUrl);
|
||||||
|
const sanitizedFilename = filename
|
||||||
|
? this.sanitizeFilename(filename)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (sanitizedFilename) {
|
||||||
|
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||||
|
} else {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] No filename extracted, aria2 will use default`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "start" as const,
|
||||||
|
game_id: downloadId,
|
||||||
|
url: directUrl,
|
||||||
|
save_path: savePath,
|
||||||
|
out: sanitizedFilename,
|
||||||
|
allow_multiple_connections: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async shouldUseJsDownloader(): Promise<boolean> {
|
||||||
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
|
levelKeys.userPreferences,
|
||||||
|
{ valueEncoding: "json" }
|
||||||
|
);
|
||||||
|
// Default to true - native HTTP downloader is enabled by default (opt-out)
|
||||||
|
return userPreferences?.useNativeHttpDownloader ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isHttpDownloader(downloader: Downloader): boolean {
|
||||||
|
return downloader !== Downloader.Torrent;
|
||||||
|
}
|
||||||
|
|
||||||
public static async startRPC(
|
public static async startRPC(
|
||||||
download?: Download,
|
download?: Download,
|
||||||
@@ -52,7 +168,87 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getDownloadStatus() {
|
private static async getDownloadStatusFromJs(): Promise<DownloadProgress | null> {
|
||||||
|
if (!this.downloadingGameId) return null;
|
||||||
|
|
||||||
|
const downloadId = this.downloadingGameId;
|
||||||
|
|
||||||
|
// Return a "preparing" status while fetching download options
|
||||||
|
if (this.isPreparingDownload) {
|
||||||
|
try {
|
||||||
|
const download = await downloadsSublevel.get(downloadId);
|
||||||
|
if (!download) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPeers: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
timeRemaining: -1,
|
||||||
|
isDownloadingMetadata: true, // Use this to indicate "preparing"
|
||||||
|
isCheckingFiles: false,
|
||||||
|
progress: 0,
|
||||||
|
gameId: downloadId,
|
||||||
|
download,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.jsDownloader) return null;
|
||||||
|
|
||||||
|
const status = this.jsDownloader.getDownloadStatus();
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const download = await downloadsSublevel.get(downloadId);
|
||||||
|
if (!download) return null;
|
||||||
|
|
||||||
|
const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } =
|
||||||
|
status;
|
||||||
|
|
||||||
|
// Only update fileSize in database if we actually know it (> 0)
|
||||||
|
// Otherwise keep the existing value to avoid showing "0 B"
|
||||||
|
const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize;
|
||||||
|
|
||||||
|
const updatedDownload = {
|
||||||
|
...download,
|
||||||
|
bytesDownloaded,
|
||||||
|
fileSize: effectiveFileSize,
|
||||||
|
progress,
|
||||||
|
folderName,
|
||||||
|
status:
|
||||||
|
status.status === "complete"
|
||||||
|
? ("complete" as const)
|
||||||
|
: ("active" as const),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.status === "active" || status.status === "complete") {
|
||||||
|
await downloadsSublevel.put(downloadId, updatedDownload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPeers: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
downloadSpeed,
|
||||||
|
timeRemaining: calculateETA(
|
||||||
|
effectiveFileSize ?? 0,
|
||||||
|
bytesDownloaded,
|
||||||
|
downloadSpeed
|
||||||
|
),
|
||||||
|
isDownloadingMetadata: false,
|
||||||
|
isCheckingFiles: false,
|
||||||
|
progress,
|
||||||
|
gameId: downloadId,
|
||||||
|
download: updatedDownload,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[DownloadManager] Error getting JS download status:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
|
||||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||||
"/status"
|
"/status"
|
||||||
);
|
);
|
||||||
@@ -101,17 +297,23 @@ export class DownloadManager {
|
|||||||
gameId: downloadId,
|
gameId: downloadId,
|
||||||
download,
|
download,
|
||||||
} as DownloadProgress;
|
} as DownloadProgress;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
|
||||||
|
if (this.usingJsDownloader) {
|
||||||
|
return this.getDownloadStatusFromJs();
|
||||||
|
}
|
||||||
|
return this.getDownloadStatusFromRpc();
|
||||||
|
}
|
||||||
|
|
||||||
public static async watchDownloads() {
|
public static async watchDownloads() {
|
||||||
const status = await this.getDownloadStatus();
|
const status = await this.getDownloadStatus();
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
if (status) {
|
|
||||||
const { gameId, progress } = status;
|
const { gameId, progress } = status;
|
||||||
|
|
||||||
const [download, game] = await Promise.all([
|
const [download, game] = await Promise.all([
|
||||||
downloadsSublevel.get(gameId),
|
downloadsSublevel.get(gameId),
|
||||||
gamesSublevel.get(gameId),
|
gamesSublevel.get(gameId),
|
||||||
@@ -119,31 +321,71 @@ export class DownloadManager {
|
|||||||
|
|
||||||
if (!download || !game) return;
|
if (!download || !game) return;
|
||||||
|
|
||||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
this.sendProgressUpdate(progress, status, game);
|
||||||
levelKeys.userPreferences,
|
|
||||||
{
|
if (progress === 1) {
|
||||||
valueEncoding: "json",
|
await this.handleDownloadCompletion(download, game, gameId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sendProgressUpdate(
|
||||||
|
progress: number,
|
||||||
|
status: DownloadProgress,
|
||||||
|
game: any
|
||||||
|
) {
|
||||||
|
if (WindowManager.mainWindow) {
|
||||||
|
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||||
|
WindowManager.mainWindow.webContents.send(
|
||||||
|
"on-download-progress",
|
||||||
|
structuredClone({ ...status, game })
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shouldExtract = download.automaticallyExtract;
|
private static async handleDownloadCompletion(
|
||||||
|
download: Download,
|
||||||
// Handle download completion BEFORE sending progress to renderer
|
game: any,
|
||||||
// This ensures extraction starts and DB is updated before UI reacts
|
gameId: string
|
||||||
if (progress === 1 && download) {
|
) {
|
||||||
publishDownloadCompleteNotification(game);
|
publishDownloadCompleteNotification(game);
|
||||||
|
|
||||||
if (
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
userPreferences?.seedAfterDownloadComplete &&
|
levelKeys.userPreferences,
|
||||||
download.downloader === Downloader.Torrent
|
{ valueEncoding: "json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.updateDownloadStatus(
|
||||||
|
download,
|
||||||
|
gameId,
|
||||||
|
userPreferences?.seedAfterDownloadComplete
|
||||||
|
);
|
||||||
|
|
||||||
|
if (download.automaticallyExtract) {
|
||||||
|
this.handleExtraction(download, game);
|
||||||
|
} else {
|
||||||
|
// For downloads without extraction (e.g., torrents with ready-to-play files),
|
||||||
|
// search for executable in the download folder
|
||||||
|
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||||
|
gameFilesManager.searchAndBindExecutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.processNextQueuedDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async updateDownloadStatus(
|
||||||
|
download: Download,
|
||||||
|
gameId: string,
|
||||||
|
shouldSeed?: boolean
|
||||||
) {
|
) {
|
||||||
|
const shouldExtract = download.automaticallyExtract;
|
||||||
|
|
||||||
|
if (shouldSeed && download.downloader === Downloader.Torrent) {
|
||||||
await downloadsSublevel.put(gameId, {
|
await downloadsSublevel.put(gameId, {
|
||||||
...download,
|
...download,
|
||||||
status: "seeding",
|
status: "seeding",
|
||||||
shouldSeed: true,
|
shouldSeed: true,
|
||||||
queued: false,
|
queued: false,
|
||||||
extracting: shouldExtract,
|
extracting: shouldExtract,
|
||||||
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await downloadsSublevel.put(gameId, {
|
await downloadsSublevel.put(gameId, {
|
||||||
@@ -152,26 +394,13 @@ export class DownloadManager {
|
|||||||
shouldSeed: false,
|
shouldSeed: false,
|
||||||
queued: false,
|
queued: false,
|
||||||
extracting: shouldExtract,
|
extracting: shouldExtract,
|
||||||
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.cancelDownload(gameId);
|
this.cancelDownload(gameId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldExtract) {
|
private static handleExtraction(download: Download, game: any) {
|
||||||
// Send initial extraction progress BEFORE download progress
|
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||||
// This ensures the UI shows extraction immediately
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
|
||||||
"on-extraction-progress",
|
|
||||||
game.shop,
|
|
||||||
game.objectId,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const gameFilesManager = new GameFilesManager(
|
|
||||||
game.shop,
|
|
||||||
game.objectId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
||||||
@@ -179,27 +408,26 @@ export class DownloadManager {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
gameFilesManager.extractDownloadedFile();
|
gameFilesManager.extractDownloadedFile();
|
||||||
} else {
|
} else if (download.folderName) {
|
||||||
gameFilesManager
|
gameFilesManager
|
||||||
.extractFilesInDirectory(
|
.extractFilesInDirectory(
|
||||||
path.join(download.downloadPath, download.folderName!)
|
path.join(download.downloadPath, download.folderName)
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => gameFilesManager.setExtractionComplete());
|
||||||
gameFilesManager.setExtractionComplete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async processNextQueuedDownload() {
|
||||||
const downloads = await downloadsSublevel
|
const downloads = await downloadsSublevel
|
||||||
.values()
|
.values()
|
||||||
.all()
|
.all()
|
||||||
.then((games) => {
|
.then((games) =>
|
||||||
return orderBy(
|
sortBy(
|
||||||
games.filter((game) => game.status === "paused" && game.queued),
|
games.filter((game) => game.status === "paused" && game.queued),
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"desc"
|
"DESC"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const [nextItemOnQueue] = downloads;
|
const [nextItemOnQueue] = downloads;
|
||||||
|
|
||||||
@@ -207,20 +435,8 @@ export class DownloadManager {
|
|||||||
this.resumeDownload(nextItemOnQueue);
|
this.resumeDownload(nextItemOnQueue);
|
||||||
} else {
|
} else {
|
||||||
this.downloadingGameId = null;
|
this.downloadingGameId = null;
|
||||||
}
|
this.usingJsDownloader = false;
|
||||||
}
|
this.jsDownloader = null;
|
||||||
|
|
||||||
// Send progress to renderer after completion handling
|
|
||||||
if (WindowManager.mainWindow && download) {
|
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
|
||||||
WindowManager.mainWindow.webContents.send(
|
|
||||||
"on-download-progress",
|
|
||||||
structuredClone({
|
|
||||||
...status,
|
|
||||||
game,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,12 +476,17 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
||||||
|
if (this.usingJsDownloader && this.jsDownloader) {
|
||||||
|
logger.log("[DownloadManager] Pausing JS download");
|
||||||
|
this.jsDownloader.pauseDownload();
|
||||||
|
} else {
|
||||||
await PythonRPC.rpc
|
await PythonRPC.rpc
|
||||||
.post("/action", {
|
.post("/action", {
|
||||||
action: "pause",
|
action: "pause",
|
||||||
game_id: downloadKey,
|
game_id: downloadKey,
|
||||||
} as PauseDownloadPayload)
|
} as PauseDownloadPayload)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadKey === this.downloadingGameId) {
|
if (downloadKey === this.downloadingGameId) {
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
@@ -278,19 +499,23 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
||||||
|
if (this.usingJsDownloader && this.jsDownloader) {
|
||||||
|
logger.log("[DownloadManager] Cancelling JS download");
|
||||||
|
this.jsDownloader.cancelDownload();
|
||||||
|
this.jsDownloader = null;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
} else if (!this.isPreparingDownload) {
|
||||||
await PythonRPC.rpc
|
await PythonRPC.rpc
|
||||||
.post("/action", {
|
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||||
action: "cancel",
|
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||||
game_id: downloadKey,
|
}
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
logger.error("Failed to cancel game download", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (downloadKey === this.downloadingGameId) {
|
if (downloadKey === this.downloadingGameId) {
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
|
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
|
||||||
this.downloadingGameId = null;
|
this.downloadingGameId = null;
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +535,241 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async getJsDownloadOptions(download: Download): Promise<{
|
||||||
|
url: string;
|
||||||
|
savePath: string;
|
||||||
|
filename?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} | null> {
|
||||||
|
const resumingFilename = download.folderName || undefined;
|
||||||
|
|
||||||
|
switch (download.downloader) {
|
||||||
|
case Downloader.Gofile:
|
||||||
|
return this.getGofileDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.PixelDrain:
|
||||||
|
return this.getPixelDrainDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Datanodes:
|
||||||
|
return this.getDatanodesDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Buzzheavier:
|
||||||
|
return this.getBuzzheavierDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.FuckingFast:
|
||||||
|
return this.getFuckingFastDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Mediafire:
|
||||||
|
return this.getMediafireDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.RealDebrid:
|
||||||
|
return this.getRealDebridDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.TorBox:
|
||||||
|
return this.getTorBoxDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Hydra:
|
||||||
|
return this.getHydraDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.VikingFile:
|
||||||
|
return this.getVikingFileDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Rootz:
|
||||||
|
return this.getRootzDownloadOptions(download, resumingFilename);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getGofileDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const id = download.uri.split("/").pop();
|
||||||
|
const token = await GofileApi.authorize();
|
||||||
|
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||||
|
await GofileApi.checkDownloadUrl(downloadLink);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadLink
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadLink,
|
||||||
|
download.downloadPath,
|
||||||
|
filename,
|
||||||
|
{ Cookie: `accountToken=${token}` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getPixelDrainDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const id = download.uri.split("/").pop();
|
||||||
|
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getDatanodesDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getBuzzheavierDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
directUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
directUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getFuckingFastDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
directUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
directUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getMediafireDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getRealDebridDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||||
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getTorBoxDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||||
|
if (!url) return null;
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
url,
|
||||||
|
download.downloadPath,
|
||||||
|
resumingFilename || name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getHydraDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
|
||||||
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getVikingFileDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getRootzDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static async getDownloadPayload(download: Download) {
|
private static async getDownloadPayload(download: Download) {
|
||||||
const downloadId = levelKeys.game(download.shop, download.objectId);
|
const downloadId = levelKeys.game(download.shop, download.objectId);
|
||||||
|
|
||||||
@@ -318,7 +778,6 @@ export class DownloadManager {
|
|||||||
const id = download.uri.split("/").pop();
|
const id = download.uri.split("/").pop();
|
||||||
const token = await GofileApi.authorize();
|
const token = await GofileApi.authorize();
|
||||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||||
|
|
||||||
await GofileApi.checkDownloadUrl(downloadLink);
|
await GofileApi.checkDownloadUrl(downloadLink);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -342,15 +801,6 @@ export class DownloadManager {
|
|||||||
save_path: download.downloadPath,
|
save_path: download.downloadPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case Downloader.Qiwi: {
|
|
||||||
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
|
|
||||||
return {
|
|
||||||
action: "start",
|
|
||||||
game_id: downloadId,
|
|
||||||
url: downloadUrl,
|
|
||||||
save_path: download.downloadPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case Downloader.Datanodes: {
|
case Downloader.Datanodes: {
|
||||||
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
||||||
return {
|
return {
|
||||||
@@ -360,9 +810,50 @@ export class DownloadManager {
|
|||||||
save_path: download.downloadPath,
|
save_path: download.downloadPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case Downloader.Buzzheavier: {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||||
|
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
||||||
|
return this.createDownloadPayload(
|
||||||
|
directUrl,
|
||||||
|
download.uri,
|
||||||
|
downloadId,
|
||||||
|
download.downloadPath
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[DownloadManager] Error processing Buzzheavier download:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case Downloader.FuckingFast: {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||||
|
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
||||||
|
return this.createDownloadPayload(
|
||||||
|
directUrl,
|
||||||
|
download.uri,
|
||||||
|
downloadId,
|
||||||
|
download.downloadPath
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[DownloadManager] Error processing FuckingFast download:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
case Downloader.Mediafire: {
|
case Downloader.Mediafire: {
|
||||||
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action: "start",
|
||||||
game_id: downloadId,
|
game_id: downloadId,
|
||||||
@@ -379,7 +870,6 @@ export class DownloadManager {
|
|||||||
};
|
};
|
||||||
case Downloader.RealDebrid: {
|
case Downloader.RealDebrid: {
|
||||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -392,7 +882,6 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
case Downloader.TorBox: {
|
case Downloader.TorBox: {
|
||||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||||
|
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action: "start",
|
||||||
@@ -407,7 +896,6 @@ export class DownloadManager {
|
|||||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||||
download.uri
|
download.uri
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -418,12 +906,75 @@ export class DownloadManager {
|
|||||||
allow_multiple_connections: true,
|
allow_multiple_connections: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case Downloader.VikingFile: {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
|
||||||
|
return this.createDownloadPayload(
|
||||||
|
downloadUrl,
|
||||||
|
download.uri,
|
||||||
|
downloadId,
|
||||||
|
download.downloadPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case Downloader.Rootz: {
|
||||||
|
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
|
||||||
|
return {
|
||||||
|
action: "start",
|
||||||
|
game_id: downloadId,
|
||||||
|
url: downloadUrl,
|
||||||
|
save_path: download.downloadPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async startDownload(download: Download) {
|
static async startDownload(download: Download) {
|
||||||
|
const useJsDownloader = await this.shouldUseJsDownloader();
|
||||||
|
const isHttp = this.isHttpDownloader(download.downloader);
|
||||||
|
const downloadId = levelKeys.game(download.shop, download.objectId);
|
||||||
|
|
||||||
|
if (useJsDownloader && isHttp) {
|
||||||
|
logger.log("[DownloadManager] Using JS HTTP downloader");
|
||||||
|
|
||||||
|
// Set preparing state immediately so UI knows download is starting
|
||||||
|
this.downloadingGameId = downloadId;
|
||||||
|
this.isPreparingDownload = true;
|
||||||
|
this.usingJsDownloader = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await this.getJsDownloadOptions(download);
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
this.downloadingGameId = null;
|
||||||
|
throw new Error("Failed to get download options for JS downloader");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jsDownloader = new JsHttpDownloader();
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
|
||||||
|
this.jsDownloader.startDownload(options).catch((err) => {
|
||||||
|
logger.error("[DownloadManager] JS download error:", err);
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
this.jsDownloader = null;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
this.downloadingGameId = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log("[DownloadManager] Using Python RPC downloader");
|
||||||
const payload = await this.getDownloadPayload(download);
|
const payload = await this.getDownloadPayload(download);
|
||||||
await PythonRPC.rpc.post("/action", payload);
|
await PythonRPC.rpc.post("/action", payload);
|
||||||
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
|
this.downloadingGameId = downloadId;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./download-manager";
|
export * from "./download-manager";
|
||||||
export * from "./real-debrid";
|
export * from "./real-debrid";
|
||||||
export * from "./torbox";
|
export * from "./torbox";
|
||||||
|
export * from "./js-http-downloader";
|
||||||
|
|||||||
380
src/main/services/download/js-http-downloader.ts
Normal file
380
src/main/services/download/js-http-downloader.ts
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
export interface JsHttpDownloaderStatus {
|
||||||
|
folderName: string;
|
||||||
|
fileSize: number;
|
||||||
|
progress: number;
|
||||||
|
downloadSpeed: number;
|
||||||
|
numPeers: number;
|
||||||
|
numSeeds: number;
|
||||||
|
status: "active" | "paused" | "complete" | "error";
|
||||||
|
bytesDownloaded: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsHttpDownloaderOptions {
|
||||||
|
url: string;
|
||||||
|
savePath: string;
|
||||||
|
filename?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsHttpDownloader {
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
private writeStream: fs.WriteStream | null = null;
|
||||||
|
private currentOptions: JsHttpDownloaderOptions | null = null;
|
||||||
|
|
||||||
|
private bytesDownloaded = 0;
|
||||||
|
private fileSize = 0;
|
||||||
|
private downloadSpeed = 0;
|
||||||
|
private status: "active" | "paused" | "complete" | "error" = "paused";
|
||||||
|
private folderName = "";
|
||||||
|
private lastSpeedUpdate = Date.now();
|
||||||
|
private bytesAtLastSpeedUpdate = 0;
|
||||||
|
private isDownloading = false;
|
||||||
|
|
||||||
|
async startDownload(options: JsHttpDownloaderOptions): Promise<void> {
|
||||||
|
if (this.isDownloading) {
|
||||||
|
logger.log(
|
||||||
|
"[JsHttpDownloader] Download already in progress, resuming..."
|
||||||
|
);
|
||||||
|
return this.resumeDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentOptions = options;
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
this.status = "active";
|
||||||
|
this.isDownloading = true;
|
||||||
|
|
||||||
|
const { url, savePath, filename, headers = {} } = options;
|
||||||
|
const { filePath, startByte, usedFallback } = this.prepareDownloadPath(
|
||||||
|
savePath,
|
||||||
|
filename,
|
||||||
|
url
|
||||||
|
);
|
||||||
|
const requestHeaders = this.buildRequestHeaders(headers, startByte);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.executeDownload(
|
||||||
|
url,
|
||||||
|
requestHeaders,
|
||||||
|
filePath,
|
||||||
|
startByte,
|
||||||
|
savePath,
|
||||||
|
usedFallback
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.handleDownloadError(err as Error);
|
||||||
|
} finally {
|
||||||
|
this.isDownloading = false;
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareDownloadPath(
|
||||||
|
savePath: string,
|
||||||
|
filename: string | undefined,
|
||||||
|
url: string
|
||||||
|
): { filePath: string; startByte: number; usedFallback: boolean } {
|
||||||
|
const extractedFilename = filename || this.extractFilename(url);
|
||||||
|
const usedFallback = !extractedFilename;
|
||||||
|
const resolvedFilename = extractedFilename || "download";
|
||||||
|
this.folderName = resolvedFilename;
|
||||||
|
const filePath = path.join(savePath, resolvedFilename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(savePath)) {
|
||||||
|
fs.mkdirSync(savePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let startByte = 0;
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
startByte = stats.size;
|
||||||
|
this.bytesDownloaded = startByte;
|
||||||
|
logger.log(`[JsHttpDownloader] Resuming download from byte ${startByte}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetSpeedTracking();
|
||||||
|
return { filePath, startByte, usedFallback };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRequestHeaders(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
startByte: number
|
||||||
|
): Record<string, string> {
|
||||||
|
const requestHeaders: Record<string, string> = { ...headers };
|
||||||
|
if (startByte > 0) {
|
||||||
|
requestHeaders["Range"] = `bytes=${startByte}-`;
|
||||||
|
}
|
||||||
|
return requestHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetSpeedTracking(): void {
|
||||||
|
this.lastSpeedUpdate = Date.now();
|
||||||
|
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
|
||||||
|
this.downloadSpeed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFileSize(response: Response, startByte: number): void {
|
||||||
|
const contentRange = response.headers.get("content-range");
|
||||||
|
if (contentRange) {
|
||||||
|
const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange);
|
||||||
|
if (match) {
|
||||||
|
this.fileSize = Number.parseInt(match[1], 10);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
if (contentLength) {
|
||||||
|
this.fileSize = startByte + Number.parseInt(contentLength, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeDownload(
|
||||||
|
url: string,
|
||||||
|
requestHeaders: Record<string, string>,
|
||||||
|
filePath: string,
|
||||||
|
startByte: number,
|
||||||
|
savePath: string,
|
||||||
|
usedFallback: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
signal: this.abortController?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 416 Range Not Satisfiable - existing file is larger than server file
|
||||||
|
// This happens when downloading same game from different source
|
||||||
|
if (response.status === 416 && startByte > 0) {
|
||||||
|
logger.log(
|
||||||
|
"[JsHttpDownloader] Range not satisfiable, deleting existing file and restarting"
|
||||||
|
);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
this.bytesDownloaded = 0;
|
||||||
|
this.resetSpeedTracking();
|
||||||
|
|
||||||
|
// Retry without Range header
|
||||||
|
const headersWithoutRange = { ...requestHeaders };
|
||||||
|
delete headersWithoutRange["Range"];
|
||||||
|
|
||||||
|
return this.executeDownload(
|
||||||
|
url,
|
||||||
|
headersWithoutRange,
|
||||||
|
filePath,
|
||||||
|
0,
|
||||||
|
savePath,
|
||||||
|
usedFallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.parseFileSize(response, startByte);
|
||||||
|
|
||||||
|
// If we used "download" fallback, try to get filename from Content-Disposition
|
||||||
|
let actualFilePath = filePath;
|
||||||
|
if (usedFallback && startByte === 0) {
|
||||||
|
const headerFilename = this.parseContentDisposition(response);
|
||||||
|
if (headerFilename) {
|
||||||
|
actualFilePath = path.join(savePath, headerFilename);
|
||||||
|
this.folderName = headerFilename;
|
||||||
|
logger.log(
|
||||||
|
`[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Response body is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = startByte > 0 ? "a" : "w";
|
||||||
|
this.writeStream = fs.createWriteStream(actualFilePath, { flags });
|
||||||
|
|
||||||
|
const readableStream = this.createReadableStream(response.body.getReader());
|
||||||
|
await pipeline(readableStream, this.writeStream);
|
||||||
|
|
||||||
|
this.status = "complete";
|
||||||
|
this.downloadSpeed = 0;
|
||||||
|
logger.log("[JsHttpDownloader] Download complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseContentDisposition(response: Response): string | undefined {
|
||||||
|
const header = response.headers.get("content-disposition");
|
||||||
|
if (!header) return undefined;
|
||||||
|
|
||||||
|
// Try to extract filename from Content-Disposition header
|
||||||
|
// Formats: attachment; filename="file.zip" or attachment; filename=file.zip
|
||||||
|
const filenameMatch = /filename\*?=['"]?(?:UTF-8'')?([^"';\n]+)['"]?/i.exec(
|
||||||
|
header
|
||||||
|
);
|
||||||
|
if (filenameMatch?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(filenameMatch[1].trim());
|
||||||
|
} catch {
|
||||||
|
return filenameMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createReadableStream(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>
|
||||||
|
): Readable {
|
||||||
|
const onChunk = (length: number) => {
|
||||||
|
this.bytesDownloaded += length;
|
||||||
|
this.updateSpeed();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Readable({
|
||||||
|
read() {
|
||||||
|
reader
|
||||||
|
.read()
|
||||||
|
.then(({ done, value }) => {
|
||||||
|
if (done) {
|
||||||
|
this.push(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChunk(value.length);
|
||||||
|
this.push(Buffer.from(value));
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (err.name === "AbortError") {
|
||||||
|
this.push(null);
|
||||||
|
} else {
|
||||||
|
this.destroy(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDownloadError(err: Error): void {
|
||||||
|
// Handle abort/cancellation errors - these are expected when user pauses/cancels
|
||||||
|
if (
|
||||||
|
err.name === "AbortError" ||
|
||||||
|
(err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE"
|
||||||
|
) {
|
||||||
|
logger.log("[JsHttpDownloader] Download aborted");
|
||||||
|
this.status = "paused";
|
||||||
|
} else {
|
||||||
|
logger.error("[JsHttpDownloader] Download error:", err);
|
||||||
|
this.status = "error";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resumeDownload(): Promise<void> {
|
||||||
|
if (!this.currentOptions) {
|
||||||
|
throw new Error("No download options available for resume");
|
||||||
|
}
|
||||||
|
this.isDownloading = false;
|
||||||
|
await this.startDownload(this.currentOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseDownload(): void {
|
||||||
|
if (this.abortController) {
|
||||||
|
logger.log("[JsHttpDownloader] Pausing download");
|
||||||
|
this.abortController.abort();
|
||||||
|
this.status = "paused";
|
||||||
|
this.downloadSpeed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelDownload(deleteFile = true): void {
|
||||||
|
if (this.abortController) {
|
||||||
|
logger.log("[JsHttpDownloader] Cancelling download");
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
if (deleteFile && this.currentOptions && this.status !== "complete") {
|
||||||
|
const filePath = path.join(this.currentOptions.savePath, this.folderName);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
logger.log("[JsHttpDownloader] Deleted partial file");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
"[JsHttpDownloader] Failed to delete partial file:",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDownloadStatus(): JsHttpDownloaderStatus | null {
|
||||||
|
if (!this.currentOptions && this.status !== "active") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
folderName: this.folderName,
|
||||||
|
fileSize: this.fileSize,
|
||||||
|
progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0,
|
||||||
|
downloadSpeed: this.downloadSpeed,
|
||||||
|
numPeers: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
status: this.status,
|
||||||
|
bytesDownloaded: this.bytesDownloaded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSpeed(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - this.lastSpeedUpdate) / 1000;
|
||||||
|
|
||||||
|
if (elapsed >= 1) {
|
||||||
|
const bytesDelta = this.bytesDownloaded - this.bytesAtLastSpeedUpdate;
|
||||||
|
this.downloadSpeed = bytesDelta / elapsed;
|
||||||
|
this.lastSpeedUpdate = now;
|
||||||
|
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFilename(url: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
const pathParts = pathname.split("/");
|
||||||
|
const filename = pathParts.at(-1);
|
||||||
|
|
||||||
|
if (filename?.includes(".") && filename.length > 0) {
|
||||||
|
return decodeURIComponent(filename);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
if (this.writeStream) {
|
||||||
|
this.writeStream.close();
|
||||||
|
this.writeStream = null;
|
||||||
|
}
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(): void {
|
||||||
|
this.currentOptions = null;
|
||||||
|
this.bytesDownloaded = 0;
|
||||||
|
this.fileSize = 0;
|
||||||
|
this.downloadSpeed = 0;
|
||||||
|
this.status = "paused";
|
||||||
|
this.folderName = "";
|
||||||
|
this.isDownloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main/services/game-executables.ts
Normal file
13
src/main/services/game-executables.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { gameExecutables } from "./process-watcher";
|
||||||
|
|
||||||
|
export class GameExecutables {
|
||||||
|
static getExecutablesForGame(objectId: string): string[] | null {
|
||||||
|
const executables = gameExecutables[objectId];
|
||||||
|
|
||||||
|
if (!executables || executables.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return executables.map((exe) => exe.exe);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { SevenZip, ExtractionProgress } from "./7zip";
|
|||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { publishExtractionCompleteNotification } from "./notifications";
|
import { publishExtractionCompleteNotification } from "./notifications";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import { GameExecutables } from "./game-executables";
|
||||||
|
|
||||||
const PROGRESS_THROTTLE_MS = 1000;
|
const PROGRESS_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
@@ -151,6 +152,100 @@ export class GameFilesManager {
|
|||||||
if (publishNotification && game) {
|
if (publishNotification && game) {
|
||||||
publishExtractionCompleteNotification(game);
|
publishExtractionCompleteNotification(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.searchAndBindExecutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchAndBindExecutable(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [download, game] = await Promise.all([
|
||||||
|
downloadsSublevel.get(this.gameKey),
|
||||||
|
gamesSublevel.get(this.gameKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!download || !game || game.executablePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executableNames = GameExecutables.getExecutablesForGame(
|
||||||
|
this.objectId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!executableNames || executableNames.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!download.folderName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameFolderPath = path.join(
|
||||||
|
download.downloadPath,
|
||||||
|
download.folderName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(gameFolderPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundExePath = await this.findExecutableInFolder(
|
||||||
|
gameFolderPath,
|
||||||
|
executableNames
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundExePath) {
|
||||||
|
logger.info(
|
||||||
|
`[GameFilesManager] Auto-detected executable for ${this.objectId}: ${foundExePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await gamesSublevel.put(this.gameKey, {
|
||||||
|
...game,
|
||||||
|
executablePath: foundExePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`[GameFilesManager] Error searching for executable: ${this.objectId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findExecutableInFolder(
|
||||||
|
folderPath: string,
|
||||||
|
executableNames: string[]
|
||||||
|
): Promise<string | null> {
|
||||||
|
const normalizedNames = new Set(
|
||||||
|
executableNames.map((name) => name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(folderPath, {
|
||||||
|
withFileTypes: true,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
|
||||||
|
const fileName = entry.name.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedNames.has(fileName)) {
|
||||||
|
const parentPath =
|
||||||
|
"parentPath" in entry
|
||||||
|
? entry.parentPath
|
||||||
|
: (entry as unknown as { path?: string }).path || folderPath;
|
||||||
|
|
||||||
|
return path.join(parentPath, entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail if folder cannot be read
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractDownloadedFile() {
|
async extractDownloadedFile() {
|
||||||
|
|||||||
100
src/main/services/hosters/buzzheavier.ts
Normal file
100
src/main/services/hosters/buzzheavier.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import {
|
||||||
|
HOSTER_USER_AGENT,
|
||||||
|
extractHosterFilename,
|
||||||
|
handleHosterError,
|
||||||
|
} from "./fuckingfast";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
export class BuzzheavierApi {
|
||||||
|
private static readonly BUZZHEAVIER_DOMAINS = [
|
||||||
|
"buzzheavier.com",
|
||||||
|
"bzzhr.co",
|
||||||
|
"fuckingfast.net",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static isSupportedDomain(url: string): boolean {
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const baseUrl = url.split("#")[0];
|
||||||
|
logger.log(
|
||||||
|
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await axios.get(baseUrl, {
|
||||||
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
|
timeout: 30000,
|
||||||
|
httpAgent: new http.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
httpsAgent: new https.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadUrl = `${baseUrl}/download`;
|
||||||
|
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
|
||||||
|
const headResponse = await axios.head(downloadUrl, {
|
||||||
|
headers: {
|
||||||
|
"hx-current-url": baseUrl,
|
||||||
|
"hx-request": "true",
|
||||||
|
referer: baseUrl,
|
||||||
|
"User-Agent": HOSTER_USER_AGENT,
|
||||||
|
},
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) =>
|
||||||
|
status === 200 || status === 204 || status === 301 || status === 302,
|
||||||
|
timeout: 30000,
|
||||||
|
httpAgent: new http.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
httpsAgent: new https.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||||
|
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||||
|
if (!hxRedirect) {
|
||||||
|
logger.error(
|
||||||
|
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
"Could not extract download link. File may be deleted or is a directory."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = new URL(baseUrl).hostname;
|
||||||
|
const directLink = hxRedirect.startsWith("/dl/")
|
||||||
|
? `https://${domain}${hxRedirect}`
|
||||||
|
: hxRedirect;
|
||||||
|
logger.log(`[Buzzheavier] Extracted direct link`);
|
||||||
|
return directLink;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
|
||||||
|
handleHosterError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getDirectLink(url: string): Promise<string> {
|
||||||
|
if (!this.isSupportedDomain(url)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getBuzzheavierDirectLink(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getFilename(
|
||||||
|
url: string,
|
||||||
|
directUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
return extractHosterFilename(url, directUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/main/services/hosters/fuckingfast.ts
Normal file
129
src/main/services/hosters/fuckingfast.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
export const HOSTER_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
|
||||||
|
|
||||||
|
export async function extractHosterFilename(
|
||||||
|
url: string,
|
||||||
|
directUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (url.includes("#")) {
|
||||||
|
const fragment = url.split("#")[1];
|
||||||
|
if (fragment && !fragment.startsWith("http")) {
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directUrl) {
|
||||||
|
try {
|
||||||
|
const response = await axios.head(directUrl, {
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentDisposition = response.headers["content-disposition"];
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
|
||||||
|
contentDisposition
|
||||||
|
);
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
return filenameMatch[1].replace(/['"]/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlPath = new URL(directUrl).pathname;
|
||||||
|
const filename = urlPath.split("/").pop()?.split("?")[0];
|
||||||
|
if (filename) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "downloaded_file";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleHosterError(error: unknown): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
throw new Error("File not found");
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
throw new Error("Rate limit exceeded. Please try again later.");
|
||||||
|
}
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
throw new Error("Access denied. File may be private or deleted.");
|
||||||
|
}
|
||||||
|
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FuckingFast API Class
|
||||||
|
// ============================================
|
||||||
|
export class FuckingFastApi {
|
||||||
|
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
|
||||||
|
|
||||||
|
private static readonly FUCKINGFAST_REGEX =
|
||||||
|
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
|
||||||
|
|
||||||
|
private static isSupportedDomain(url: string): boolean {
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getFuckingFastDirectLink(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = response.data;
|
||||||
|
|
||||||
|
if (html.toLowerCase().includes("rate limit")) {
|
||||||
|
logger.error(`[FuckingFast] Rate limit detected`);
|
||||||
|
throw new Error(
|
||||||
|
"Rate limit exceeded. Please wait a few minutes and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.includes("File Not Found Or Deleted")) {
|
||||||
|
logger.error(`[FuckingFast] File not found or deleted`);
|
||||||
|
throw new Error("File not found or deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = this.FUCKINGFAST_REGEX.exec(html);
|
||||||
|
if (!match || !match[1]) {
|
||||||
|
logger.error(`[FuckingFast] Could not extract download link`);
|
||||||
|
throw new Error("Could not extract download link from page");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[FuckingFast] Successfully extracted direct link`);
|
||||||
|
return match[1];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[FuckingFast] Error:`, error);
|
||||||
|
handleHosterError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getDirectLink(url: string): Promise<string> {
|
||||||
|
if (!this.isSupportedDomain(url)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getFuckingFastDirectLink(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getFilename(
|
||||||
|
url: string,
|
||||||
|
directUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
return extractHosterFilename(url, directUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
export * from "./gofile";
|
export * from "./gofile";
|
||||||
export * from "./qiwi";
|
|
||||||
export * from "./datanodes";
|
export * from "./datanodes";
|
||||||
export * from "./mediafire";
|
export * from "./mediafire";
|
||||||
export * from "./pixeldrain";
|
export * from "./pixeldrain";
|
||||||
|
export * from "./buzzheavier";
|
||||||
|
export * from "./fuckingfast";
|
||||||
|
export * from "./vikingfile";
|
||||||
|
export * from "./rootz";
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { requestWebPage } from "@main/helpers";
|
|
||||||
|
|
||||||
export class QiwiApi {
|
|
||||||
public static async getDownloadUrl(url: string) {
|
|
||||||
const document = await requestWebPage(url);
|
|
||||||
const fileName = document.querySelector("h1")?.textContent;
|
|
||||||
|
|
||||||
const slug = url.split("/").pop();
|
|
||||||
const extension = fileName?.split(".").pop();
|
|
||||||
|
|
||||||
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
|
|
||||||
|
|
||||||
return downloadUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
src/main/services/hosters/rootz.ts
Normal file
58
src/main/services/hosters/rootz.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
interface RootzApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
expiresIn: number;
|
||||||
|
expiresAt: string | null;
|
||||||
|
downloads: number;
|
||||||
|
canDelete: boolean;
|
||||||
|
fileId: string;
|
||||||
|
isMirrored: boolean;
|
||||||
|
sourceService: string | null;
|
||||||
|
adsEnabled: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RootzApi {
|
||||||
|
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const url = new URL(uri);
|
||||||
|
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (pathSegments.length < 2 || pathSegments[0] !== "d") {
|
||||||
|
throw new Error("Invalid rootz URL format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = pathSegments[1];
|
||||||
|
const apiUrl = `https://www.rootz.so/api/files/download-by-short/${id}`;
|
||||||
|
|
||||||
|
const response = await axios.get<RootzApiResponse>(apiUrl);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.url) {
|
||||||
|
return response.data.data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to get download URL from rootz API");
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const axiosError = error as AxiosError<RootzApiResponse>;
|
||||||
|
if (axiosError.response?.status === 404) {
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response.data?.error || "File not found";
|
||||||
|
logger.error(`[Rootz] ${errorMessage}`);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("[Rootz] Error fetching download URL:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/services/hosters/vikingfile.ts
Normal file
46
src/main/services/hosters/vikingfile.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
interface UnlockResponse {
|
||||||
|
link: string;
|
||||||
|
hoster: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VikingFileApi {
|
||||||
|
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||||
|
const unlockResponse = await axios.post<UnlockResponse>(
|
||||||
|
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
|
||||||
|
{ url: uri }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unlockResponse.data.link) {
|
||||||
|
throw new Error("Failed to unlock VikingFile URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = unlockResponse.data.link;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectResponse = await axios.head(redirectUrl, {
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) =>
|
||||||
|
status === 301 || status === 302 || status === 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
redirectResponse.headers.location ||
|
||||||
|
redirectResponse.status === 301 ||
|
||||||
|
redirectResponse.status === 302
|
||||||
|
) {
|
||||||
|
return redirectResponse.headers.location || redirectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirectUrl;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[VikingFile] Error following redirect, using redirect URL:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return redirectUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
|||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
|
|
||||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||||
|
|
||||||
private static secondsToMilliseconds(seconds: number) {
|
private static secondsToMilliseconds(seconds: number) {
|
||||||
return seconds * 1000;
|
return seconds * 1000;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from "./ludusavi";
|
|||||||
export * from "./cloud-sync";
|
export * from "./cloud-sync";
|
||||||
export * from "./7zip";
|
export * from "./7zip";
|
||||||
export * from "./game-files-manager";
|
export * from "./game-files-manager";
|
||||||
|
export * from "./game-executables";
|
||||||
export * from "./common-redist-manager";
|
export * from "./common-redist-manager";
|
||||||
export * from "./aria2";
|
export * from "./aria2";
|
||||||
export * from "./ws";
|
export * from "./ws";
|
||||||
@@ -20,3 +21,4 @@ export * from "./lock";
|
|||||||
export * from "./decky-plugin";
|
export * from "./decky-plugin";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./download-sources-checker";
|
export * from "./download-sources-checker";
|
||||||
|
export * from "./notifications/local-notifications";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
|
|||||||
import { SystemPath } from "../system-path";
|
import { SystemPath } from "../system-path";
|
||||||
import { getThemeSoundPath } from "@main/helpers";
|
import { getThemeSoundPath } from "@main/helpers";
|
||||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||||
|
import { LocalNotificationManager } from "./local-notifications";
|
||||||
|
|
||||||
const getStaticImage = async (path: string) => {
|
const getStaticImage = async (path: string) => {
|
||||||
return processProfileImage(path, "jpg")
|
return processProfileImage(path, "jpg")
|
||||||
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userPreferences?.downloadNotificationsEnabled) {
|
const title = t("download_complete", { ns: "notifications" });
|
||||||
new Notification({
|
const body = t("game_ready_to_install", {
|
||||||
title: t("download_complete", {
|
|
||||||
ns: "notifications",
|
|
||||||
}),
|
|
||||||
body: t("game_ready_to_install", {
|
|
||||||
ns: "notifications",
|
ns: "notifications",
|
||||||
title: game.title,
|
title: game.title,
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
if (userPreferences?.downloadNotificationsEnabled) {
|
||||||
|
new Notification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
icon: await downloadImage(game.iconUrl),
|
icon: await downloadImage(game.iconUrl),
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"DOWNLOAD_COMPLETE",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
pictureUrl: game.iconUrl,
|
||||||
|
url: `/game/${game.shop}/${game.objectId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNotificationUpdateReadyToInstall = async (
|
export const publishNotificationUpdateReadyToInstall = async (
|
||||||
version: string
|
version: string
|
||||||
) => {
|
) => {
|
||||||
new Notification({
|
const title = t("new_update_available", {
|
||||||
title: t("new_update_available", {
|
|
||||||
ns: "notifications",
|
ns: "notifications",
|
||||||
version,
|
version,
|
||||||
}),
|
});
|
||||||
body: t("restart_to_install_update", {
|
const body = t("restart_to_install_update", {
|
||||||
ns: "notifications",
|
ns: "notifications",
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
new Notification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
})
|
})
|
||||||
.on("click", () => {
|
.on("click", () => {
|
||||||
restartAndInstallUpdate();
|
restartAndInstallUpdate();
|
||||||
})
|
})
|
||||||
.show();
|
.show();
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"UPDATE_AVAILABLE",
|
||||||
|
title,
|
||||||
|
body
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNewFriendRequestNotification = async (
|
export const publishNewFriendRequestNotification = async (
|
||||||
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||||
new Notification({
|
const title = t("extraction_complete", { ns: "notifications" });
|
||||||
title: t("extraction_complete", { ns: "notifications" }),
|
const body = t("game_extracted", {
|
||||||
body: t("game_extracted", {
|
|
||||||
ns: "notifications",
|
ns: "notifications",
|
||||||
title: game.title,
|
title: game.title,
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
new Notification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
}).show();
|
}).show();
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"EXTRACTION_COMPLETE",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
url: `/game/${game.shop}/${game.objectId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNewAchievementNotification = async (info: {
|
export const publishNewAchievementNotification = async (info: {
|
||||||
|
|||||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { localNotificationsSublevel } from "@main/level";
|
||||||
|
import { WindowManager } from "../window-manager";
|
||||||
|
import type { LocalNotification, LocalNotificationType } from "@types";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export class LocalNotificationManager {
|
||||||
|
private static generateId(): string {
|
||||||
|
return crypto.randomBytes(8).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createNotification(
|
||||||
|
type: LocalNotificationType,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
options?: {
|
||||||
|
pictureUrl?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<LocalNotification> {
|
||||||
|
const id = this.generateId();
|
||||||
|
const notification: LocalNotification = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
pictureUrl: options?.pictureUrl ?? null,
|
||||||
|
url: options?.url ?? null,
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await localNotificationsSublevel.put(id, notification);
|
||||||
|
|
||||||
|
// Notify renderer about new notification
|
||||||
|
if (WindowManager.mainWindow) {
|
||||||
|
WindowManager.mainWindow.webContents.send(
|
||||||
|
"on-local-notification-created",
|
||||||
|
notification
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getNotifications(): Promise<LocalNotification[]> {
|
||||||
|
const notifications: LocalNotification[] = [];
|
||||||
|
|
||||||
|
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||||
|
notifications.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending
|
||||||
|
return notifications.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUnreadCount(): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||||
|
if (!value.isRead) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markAsRead(id: string): Promise<void> {
|
||||||
|
const notification = await localNotificationsSublevel.get(id);
|
||||||
|
if (notification) {
|
||||||
|
notification.isRead = true;
|
||||||
|
await localNotificationsSublevel.put(id, notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markAllAsRead(): Promise<void> {
|
||||||
|
const batch = localNotificationsSublevel.batch();
|
||||||
|
|
||||||
|
for await (const [key, value] of localNotificationsSublevel.iterator()) {
|
||||||
|
if (!value.isRead) {
|
||||||
|
value.isRead = true;
|
||||||
|
batch.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.write();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteNotification(id: string): Promise<void> {
|
||||||
|
await localNotificationsSublevel.del(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async clearAll(): Promise<void> {
|
||||||
|
await localNotificationsSublevel.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ const getGameExecutables = async () => {
|
|||||||
return gameExecutables;
|
return gameExecutables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const gameExecutables = await getGameExecutables();
|
export const gameExecutables = await getGameExecutables();
|
||||||
|
|
||||||
const findGamePathByProcess = async (
|
const findGamePathByProcess = async (
|
||||||
processMap: Map<string, Set<string>>,
|
processMap: Map<string, Set<string>>,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import http from "node:http";
|
||||||
|
import getPort, { portNumbers } from "get-port";
|
||||||
|
|
||||||
import cp from "node:child_process";
|
import cp from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -26,11 +28,20 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
|||||||
win32: "hydra-python-rpc.exe",
|
win32: "hydra-python-rpc.exe",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RPC_PORT_RANGE_START = 8080;
|
||||||
|
const RPC_PORT_RANGE_END = 9000;
|
||||||
|
const DEFAULT_RPC_PORT = 8084;
|
||||||
|
const HEALTH_CHECK_INTERVAL_MS = 100;
|
||||||
|
const HEALTH_CHECK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
export class PythonRPC {
|
export class PythonRPC {
|
||||||
public static readonly BITTORRENT_PORT = "5881";
|
public static readonly BITTORRENT_PORT = "5881";
|
||||||
public static readonly RPC_PORT = "8084";
|
|
||||||
public static readonly rpc = axios.create({
|
public static readonly rpc = axios.create({
|
||||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
baseURL: `http://localhost:${DEFAULT_RPC_PORT}`,
|
||||||
|
httpAgent: new http.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
private static pythonProcess: cp.ChildProcess | null = null;
|
private static pythonProcess: cp.ChildProcess | null = null;
|
||||||
@@ -58,15 +69,46 @@ export class PythonRPC {
|
|||||||
return newPassword;
|
return newPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async waitForHealthCheck(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
const response = await this.rpc.get("/healthcheck", { timeout: 1000 });
|
||||||
|
if (response.status === 200) {
|
||||||
|
pythonRpcLogger.log("RPC health check passed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet, continue polling
|
||||||
|
}
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("RPC health check timed out");
|
||||||
|
}
|
||||||
|
|
||||||
public static async spawn(
|
public static async spawn(
|
||||||
initialDownload?: GamePayload,
|
initialDownload?: GamePayload,
|
||||||
initialSeeding?: GamePayload[]
|
initialSeeding?: GamePayload[]
|
||||||
) {
|
) {
|
||||||
const rpcPassword = await this.getRPCPassword();
|
const rpcPassword = await this.getRPCPassword();
|
||||||
|
|
||||||
|
const port = await getPort({
|
||||||
|
port: [
|
||||||
|
DEFAULT_RPC_PORT,
|
||||||
|
...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rpc.defaults.baseURL = `http://localhost:${port}`;
|
||||||
|
pythonRpcLogger.log(`Using RPC port: ${port}`);
|
||||||
|
|
||||||
const commonArgs = [
|
const commonArgs = [
|
||||||
this.BITTORRENT_PORT,
|
this.BITTORRENT_PORT,
|
||||||
this.RPC_PORT,
|
String(port),
|
||||||
rpcPassword,
|
rpcPassword,
|
||||||
initialDownload ? JSON.stringify(initialDownload) : "",
|
initialDownload ? JSON.stringify(initialDownload) : "",
|
||||||
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
initialSeeding ? JSON.stringify(initialSeeding) : "",
|
||||||
@@ -87,6 +129,7 @@ export class PythonRPC {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.quit();
|
app.quit();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||||
@@ -95,7 +138,6 @@ export class PythonRPC {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logStderr(childProcess.stderr);
|
this.logStderr(childProcess.stderr);
|
||||||
|
|
||||||
this.pythonProcess = childProcess;
|
this.pythonProcess = childProcess;
|
||||||
} else {
|
} else {
|
||||||
const scriptPath = path.join(
|
const scriptPath = path.join(
|
||||||
@@ -111,11 +153,23 @@ export class PythonRPC {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logStderr(childProcess.stderr);
|
this.logStderr(childProcess.stderr);
|
||||||
|
|
||||||
this.pythonProcess = childProcess;
|
this.pythonProcess = childProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.waitForHealthCheck();
|
||||||
|
pythonRpcLogger.log(`Python RPC started successfully on port ${port}`);
|
||||||
|
} catch (err) {
|
||||||
|
pythonRpcLogger.log(`Failed to start Python RPC: ${err}`);
|
||||||
|
dialog.showErrorBox(
|
||||||
|
"RPC Error",
|
||||||
|
`Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.`
|
||||||
|
);
|
||||||
|
this.kill();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static kill() {
|
public static kill() {
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export class WindowManager {
|
|||||||
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
||||||
{
|
{
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 720,
|
height: 860,
|
||||||
minWidth: 1024,
|
minWidth: 1024,
|
||||||
minHeight: 540,
|
minHeight: 860,
|
||||||
backgroundColor: "#1c1c1c",
|
backgroundColor: "#1c1c1c",
|
||||||
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
||||||
icon,
|
icon,
|
||||||
@@ -106,7 +106,7 @@ export class WindowManager {
|
|||||||
valueEncoding: "json",
|
valueEncoding: "json",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return data ?? { isMaximized: false, height: 720, width: 1200 };
|
return data ?? { isMaximized: false, height: 860, width: 1200 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static updateInitialConfig(
|
private static updateInitialConfig(
|
||||||
@@ -138,12 +138,21 @@ export class WindowManager {
|
|||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
if (
|
if (
|
||||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||||
details.url.includes("chatwoot") ||
|
details.url.includes("chatwoot")
|
||||||
details.url.includes("workwonders")
|
|
||||||
) {
|
) {
|
||||||
return callback(details);
|
return callback(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (details.url.includes("workwonders")) {
|
||||||
|
return callback({
|
||||||
|
...details,
|
||||||
|
requestHeaders: {
|
||||||
|
Origin: "https://workwonders.app",
|
||||||
|
...details.requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userAgent = new UserAgent();
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
callback({
|
callback({
|
||||||
@@ -224,7 +233,7 @@ export class WindowManager {
|
|||||||
? {
|
? {
|
||||||
x: undefined,
|
x: undefined,
|
||||||
y: undefined,
|
y: undefined,
|
||||||
height: this.initialConfigInitializationMainWindow.height ?? 720,
|
height: this.initialConfigInitializationMainWindow.height ?? 860,
|
||||||
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
||||||
isMaximized: true,
|
isMaximized: true,
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Notification } from "@main/generated/envelope";
|
||||||
|
import { WindowManager } from "@main/services/window-manager";
|
||||||
|
|
||||||
|
export const notificationEvent = (payload: Notification) => {
|
||||||
|
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
|
||||||
|
notificationCount: payload.notificationCount,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
|
|||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { friendRequestEvent } from "./events/friend-request";
|
import { friendRequestEvent } from "./events/friend-request";
|
||||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||||
|
import { notificationEvent } from "./events/notification";
|
||||||
|
|
||||||
export class WSClient {
|
export class WSClient {
|
||||||
private static ws: WebSocket | null = null;
|
private static ws: WebSocket | null = null;
|
||||||
@@ -51,6 +52,10 @@ export class WSClient {
|
|||||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envelope.payload.oneofKind === "notification") {
|
||||||
|
notificationEvent(envelope.payload.notification);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||||
|
|||||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
|||||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||||
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
|
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
|
||||||
readonly MAIN_VITE_WS_URL: string;
|
readonly MAIN_VITE_WS_URL: string;
|
||||||
|
readonly MAIN_VITE_NIMBUS_API_URL: string;
|
||||||
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
|
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
|
||||||
readonly ELECTRON_RENDERER_URL: string;
|
readonly ELECTRON_RENDERER_URL: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
GameAchievement,
|
GameAchievement,
|
||||||
Theme,
|
Theme,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
ShortcutLocation,
|
ShortcutLocation,
|
||||||
AchievementCustomNotificationPosition,
|
AchievementCustomNotificationPosition,
|
||||||
AchievementNotificationInfo,
|
AchievementNotificationInfo,
|
||||||
@@ -205,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
||||||
openGameInstaller: (shop: GameShop, objectId: string) =>
|
openGameInstaller: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
||||||
|
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
|
||||||
|
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
|
||||||
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
|
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
|
||||||
openGameExecutablePath: (shop: GameShop, objectId: string) =>
|
openGameExecutablePath: (shop: GameShop, objectId: string) =>
|
||||||
@@ -238,6 +241,7 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
|
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
|
||||||
extractGameDownload: (shop: GameShop, objectId: string) =>
|
extractGameDownload: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("extractGameDownload", shop, objectId),
|
ipcRenderer.invoke("extractGameDownload", shop, objectId),
|
||||||
|
scanInstalledGames: () => ipcRenderer.invoke("scanInstalledGames"),
|
||||||
getDefaultWinePrefixSelectionPath: () =>
|
getDefaultWinePrefixSelectionPath: () =>
|
||||||
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
|
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
|
||||||
createSteamShortcut: (shop: GameShop, objectId: string) =>
|
createSteamShortcut: (shop: GameShop, objectId: string) =>
|
||||||
@@ -497,7 +501,6 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("updateProfile", updateProfile),
|
ipcRenderer.invoke("updateProfile", updateProfile),
|
||||||
processProfileImage: (imagePath: string) =>
|
processProfileImage: (imagePath: string) =>
|
||||||
ipcRenderer.invoke("processProfileImage", imagePath),
|
ipcRenderer.invoke("processProfileImage", imagePath),
|
||||||
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
|
|
||||||
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
@@ -507,6 +510,15 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
return () =>
|
return () =>
|
||||||
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
||||||
},
|
},
|
||||||
|
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
notification: NotificationSync
|
||||||
|
) => cb(notification);
|
||||||
|
ipcRenderer.on("on-sync-notification-count", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-sync-notification-count", listener);
|
||||||
|
},
|
||||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||||
|
|
||||||
@@ -550,6 +562,26 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
/* Notifications */
|
/* Notifications */
|
||||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
||||||
|
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
|
||||||
|
getLocalNotificationsCount: () =>
|
||||||
|
ipcRenderer.invoke("getLocalNotificationsCount"),
|
||||||
|
markLocalNotificationRead: (id: string) =>
|
||||||
|
ipcRenderer.invoke("markLocalNotificationRead", id),
|
||||||
|
markAllLocalNotificationsRead: () =>
|
||||||
|
ipcRenderer.invoke("markAllLocalNotificationsRead"),
|
||||||
|
deleteLocalNotification: (id: string) =>
|
||||||
|
ipcRenderer.invoke("deleteLocalNotification", id),
|
||||||
|
clearAllLocalNotifications: () =>
|
||||||
|
ipcRenderer.invoke("clearAllLocalNotifications"),
|
||||||
|
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
notification: unknown
|
||||||
|
) => cb(notification);
|
||||||
|
ipcRenderer.on("on-local-notification-created", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-local-notification-created", listener);
|
||||||
|
},
|
||||||
onAchievementUnlocked: (
|
onAchievementUnlocked: (
|
||||||
cb: (
|
cb: (
|
||||||
position?: AchievementCustomNotificationPosition,
|
position?: AchievementCustomNotificationPosition,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||||
|
import { WorkWonders } from "workwonders-sdk";
|
||||||
import {
|
import {
|
||||||
useAppDispatch,
|
useAppDispatch,
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
clearExtraction,
|
clearExtraction,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
|
||||||
import { useSubscription } from "./hooks/use-subscription";
|
import { useSubscription } from "./hooks/use-subscription";
|
||||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||||
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
|
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
|
||||||
@@ -53,13 +52,10 @@ export function App() {
|
|||||||
|
|
||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|
||||||
|
const workwondersRef = useRef<WorkWonders | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userDetails,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
isFriendsModalVisible,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
friendModalUserId,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
updateUserDetails,
|
updateUserDetails,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
@@ -120,7 +116,33 @@ export function App() {
|
|||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [updateLibrary]);
|
}, [updateLibrary]);
|
||||||
|
|
||||||
useEffect(() => {
|
const setupWorkWonders = useCallback(
|
||||||
|
async (token?: string, locale?: string) => {
|
||||||
|
if (workwondersRef.current) return;
|
||||||
|
|
||||||
|
const possibleLocales = ["en", "pt", "ru"];
|
||||||
|
|
||||||
|
const parsedLocale =
|
||||||
|
possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en";
|
||||||
|
|
||||||
|
workwondersRef.current = new WorkWonders();
|
||||||
|
await workwondersRef.current.init({
|
||||||
|
organization: "hydra",
|
||||||
|
token,
|
||||||
|
locale: parsedLocale,
|
||||||
|
});
|
||||||
|
|
||||||
|
await workwondersRef.current.changelog.initChangelogWidget();
|
||||||
|
workwondersRef.current.changelog.initChangelogWidgetMini();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
workwondersRef.current.feedback.initFeedbackWidget();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workwondersRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupExternalResources = useCallback(async () => {
|
||||||
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
||||||
|
|
||||||
if (cachedUserDetails) {
|
if (cachedUserDetails) {
|
||||||
@@ -131,28 +153,31 @@ export function App() {
|
|||||||
dispatch(setProfileBackground(profileBackground));
|
dispatch(setProfileBackground(profileBackground));
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUserDetails()
|
const userPreferences = await window.electron.getUserPreferences();
|
||||||
.then((response) => {
|
const userDetails = await fetchUserDetails().catch(() => null);
|
||||||
if (response) {
|
|
||||||
updateUserDetails(response);
|
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (document.getElementById("external-resources")) return;
|
|
||||||
|
|
||||||
|
if (userDetails) {
|
||||||
|
updateUserDetails(userDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
|
||||||
|
|
||||||
|
if (!document.getElementById("external-resources")) {
|
||||||
const $script = document.createElement("script");
|
const $script = document.createElement("script");
|
||||||
$script.id = "external-resources";
|
$script.id = "external-resources";
|
||||||
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
||||||
document.head.appendChild($script);
|
document.head.appendChild($script);
|
||||||
});
|
}
|
||||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
}, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setupExternalResources();
|
||||||
|
}, [setupExternalResources]);
|
||||||
|
|
||||||
const onSignIn = useCallback(() => {
|
const onSignIn = useCallback(() => {
|
||||||
fetchUserDetails().then((response) => {
|
fetchUserDetails().then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
updateUserDetails(response);
|
updateUserDetails(response);
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
showSuccessToast(t("successfully_signed_in"));
|
showSuccessToast(t("successfully_signed_in"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -211,6 +236,7 @@ export function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||||
|
workwondersRef.current?.notifyUrlChange();
|
||||||
}, [location.pathname, location.search]);
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -305,15 +331,6 @@ export function App() {
|
|||||||
onClose={() => setShowArchiveDeletionModal(false)}
|
onClose={() => setShowArchiveDeletionModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{userDetails && (
|
|
||||||
<UserFriendModal
|
|
||||||
visible={isFriendsModalVisible}
|
|
||||||
initialTab={friendRequetsModalTab}
|
|
||||||
onClose={hideFriendsModal}
|
|
||||||
userId={friendModalUserId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
animation: dropdown-menu-fade-in 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__group {
|
&__group {
|
||||||
@@ -66,3 +67,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dropdown-menu-fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
@use "../../scss/globals.scss";
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.fullscreen-media-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: globals.$backdrop-z-index;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fullscreen-media-modal {
|
.fullscreen-media-modal {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -7,12 +23,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
|
||||||
&__close-button {
|
&__close-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(globals.$spacing-unit * 4);
|
top: calc(globals.$spacing-unit * 5);
|
||||||
right: calc(globals.$spacing-unit * 3);
|
right: calc(globals.$spacing-unit * 4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: globals.$body-color;
|
color: globals.$body-color;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createPortal } from "react-dom";
|
|||||||
import { XIcon } from "@primer/octicons-react";
|
import { XIcon } from "@primer/octicons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Backdrop } from "../backdrop/backdrop";
|
|
||||||
import "./fullscreen-media-modal.scss";
|
import "./fullscreen-media-modal.scss";
|
||||||
|
|
||||||
export interface FullscreenMediaModalProps {
|
export interface FullscreenMediaModalProps {
|
||||||
@@ -64,7 +63,7 @@ export function FullscreenMediaModal({
|
|||||||
if (!visible || !src) return null;
|
if (!visible || !src) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<Backdrop>
|
<div className="fullscreen-media-modal__overlay">
|
||||||
<dialog className="fullscreen-media-modal" open aria-label={alt}>
|
<dialog className="fullscreen-media-modal" open aria-label={alt}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -82,7 +81,7 @@ export function FullscreenMediaModal({
|
|||||||
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
|
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</Backdrop>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,26 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all ease 0.2s;
|
transition: all ease 0.2s;
|
||||||
padding: globals.$spacing-unit;
|
padding: globals.$spacing-unit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #dadbe1;
|
color: #dadbe1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--scanning svg {
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section {
|
&__section {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SyncIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAppDispatch,
|
useAppDispatch,
|
||||||
@@ -12,6 +18,7 @@ import {
|
|||||||
|
|
||||||
import "./header.scss";
|
import "./header.scss";
|
||||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||||
|
import { ScanGamesModal } from "./scan-games-modal";
|
||||||
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
|
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import { SearchDropdown } from "@renderer/components";
|
import { SearchDropdown } from "@renderer/components";
|
||||||
@@ -29,9 +36,11 @@ const pathTitle: Record<string, string> = {
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scanButtonTooltipId = useId();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||||
(state) => state.window
|
(state) => state.window
|
||||||
@@ -61,6 +70,12 @@ export function Header() {
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
});
|
});
|
||||||
|
const [showScanModal, setShowScanModal] = useState(false);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [scanResult, setScanResult] = useState<{
|
||||||
|
foundGames: { title: string; executablePath: string }[];
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const { t } = useTranslation("header");
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
@@ -82,6 +97,7 @@ export function Header() {
|
|||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/profile")) return headerTitle;
|
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||||
|
if (location.pathname.startsWith("/notifications")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/library"))
|
if (location.pathname.startsWith("/library"))
|
||||||
return headerTitle || t("library");
|
return headerTitle || t("library");
|
||||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
@@ -223,20 +239,24 @@ export function Header() {
|
|||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleStartScan = async () => {
|
||||||
const prevPath = sessionStorage.getItem("prevPath");
|
if (isScanning) return;
|
||||||
const currentPath = location.pathname;
|
|
||||||
|
|
||||||
if (
|
setIsScanning(true);
|
||||||
prevPath?.startsWith("/catalogue") &&
|
setScanResult(null);
|
||||||
!currentPath.startsWith("/catalogue") &&
|
setShowScanModal(false);
|
||||||
catalogueSearchValue
|
|
||||||
) {
|
try {
|
||||||
dispatch(setFilters({ title: "" }));
|
const result = await window.electron.scanInstalledGames();
|
||||||
|
setScanResult(result);
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
sessionStorage.setItem("prevPath", currentPath);
|
const handleClearScanResult = () => {
|
||||||
}, [location.pathname, catalogueSearchValue, dispatch]);
|
setScanResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDropdownVisible) return;
|
if (!isDropdownVisible) return;
|
||||||
@@ -249,6 +269,14 @@ export function Header() {
|
|||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, [isDropdownVisible]);
|
}, [isDropdownVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("openScanModal") === "true") {
|
||||||
|
setShowScanModal(true);
|
||||||
|
searchParams.delete("openScanModal");
|
||||||
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
@@ -279,6 +307,21 @@ export function Header() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="header__section">
|
<section className="header__section">
|
||||||
|
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("header__action-button", {
|
||||||
|
"header__action-button--scanning": isScanning,
|
||||||
|
})}
|
||||||
|
onClick={() => setShowScanModal(true)}
|
||||||
|
data-tooltip-id={scanButtonTooltipId}
|
||||||
|
data-tooltip-content={t("scan_games_tooltip")}
|
||||||
|
data-tooltip-place="bottom"
|
||||||
|
>
|
||||||
|
<SyncIcon size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={searchContainerRef}
|
ref={searchContainerRef}
|
||||||
className={cn("header__search", {
|
className={cn("header__search", {
|
||||||
@@ -318,12 +361,18 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||||
|
<Tooltip id={scanButtonTooltipId} style={{ zIndex: 1 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<AutoUpdateSubHeader />
|
<AutoUpdateSubHeader />
|
||||||
|
|
||||||
<SearchDropdown
|
<SearchDropdown
|
||||||
visible={
|
visible={
|
||||||
isDropdownVisible &&
|
isDropdownVisible &&
|
||||||
(historyItems.length > 0 ||
|
(searchValue.trim().length > 0 ||
|
||||||
|
historyItems.length > 0 ||
|
||||||
suggestions.length > 0 ||
|
suggestions.length > 0 ||
|
||||||
isLoadingSuggestions)
|
isLoadingSuggestions)
|
||||||
}
|
}
|
||||||
@@ -340,6 +389,15 @@ export function Header() {
|
|||||||
currentQuery={searchValue}
|
currentQuery={searchValue}
|
||||||
searchContainerRef={searchContainerRef}
|
searchContainerRef={searchContainerRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ScanGamesModal
|
||||||
|
visible={showScanModal}
|
||||||
|
onClose={() => setShowScanModal(false)}
|
||||||
|
isScanning={isScanning}
|
||||||
|
scanResult={scanResult}
|
||||||
|
onStartScan={handleStartScan}
|
||||||
|
onClearResult={handleClearScanResult}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.scan-games-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 3);
|
||||||
|
min-width: 400px;
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
color: globals.$body-color;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no-results {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: calc(globals.$spacing-unit * 2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__scanning {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
padding: calc(globals.$spacing-unit * 3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__scanning-text {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__games-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: globals.$spacing-unit;
|
||||||
|
background-color: globals.$dark-background-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__game-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-bottom: globals.$spacing-unit;
|
||||||
|
border-bottom: 1px solid globals.$border-color;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__game-title {
|
||||||
|
color: globals.$body-color;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__game-path {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SyncIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
|
import { Button, Modal } from "@renderer/components";
|
||||||
|
|
||||||
|
import "./scan-games-modal.scss";
|
||||||
|
|
||||||
|
interface FoundGame {
|
||||||
|
title: string;
|
||||||
|
executablePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
foundGames: FoundGame[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanGamesModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
isScanning: boolean;
|
||||||
|
scanResult: ScanResult | null;
|
||||||
|
onStartScan: () => void;
|
||||||
|
onClearResult: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanGamesModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
isScanning,
|
||||||
|
scanResult,
|
||||||
|
onStartScan,
|
||||||
|
onClearResult,
|
||||||
|
}: Readonly<ScanGamesModalProps>) {
|
||||||
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartScan = () => {
|
||||||
|
onStartScan();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanAgain = () => {
|
||||||
|
onClearResult();
|
||||||
|
onStartScan();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title={t("scan_games_title")}
|
||||||
|
onClose={handleClose}
|
||||||
|
clickOutsideToClose={!isScanning}
|
||||||
|
>
|
||||||
|
<div className="scan-games-modal">
|
||||||
|
{!scanResult && !isScanning && (
|
||||||
|
<p className="scan-games-modal__description">
|
||||||
|
{t("scan_games_description")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScanning && !scanResult && (
|
||||||
|
<div className="scan-games-modal__scanning">
|
||||||
|
<SyncIcon size={24} className="scan-games-modal__spinner" />
|
||||||
|
<p className="scan-games-modal__scanning-text">
|
||||||
|
{t("scan_games_in_progress")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanResult && (
|
||||||
|
<div className="scan-games-modal__results">
|
||||||
|
{scanResult.foundGames.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="scan-games-modal__result">
|
||||||
|
{t("scan_games_result", {
|
||||||
|
found: scanResult.foundGames.length,
|
||||||
|
total: scanResult.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="scan-games-modal__games-list">
|
||||||
|
{scanResult.foundGames.map((game) => (
|
||||||
|
<li
|
||||||
|
key={game.executablePath}
|
||||||
|
className="scan-games-modal__game-item"
|
||||||
|
>
|
||||||
|
<span className="scan-games-modal__game-title">
|
||||||
|
{game.title}
|
||||||
|
</span>
|
||||||
|
<span className="scan-games-modal__game-path">
|
||||||
|
{game.executablePath}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="scan-games-modal__no-results">
|
||||||
|
{t("scan_games_no_results")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scan-games-modal__actions">
|
||||||
|
<Button theme="outline" onClick={handleClose}>
|
||||||
|
{scanResult ? t("scan_games_close") : t("scan_games_cancel")}
|
||||||
|
</Button>
|
||||||
|
{!scanResult && (
|
||||||
|
<Button onClick={handleStartScan} disabled={isScanning}>
|
||||||
|
{t("scan_games_start")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{scanResult && (
|
||||||
|
<Button onClick={handleScanAgain}>
|
||||||
|
{t("scan_games_scan_again")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import cn from "classnames";
|
|||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
description?: string;
|
description?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
@@ -115,7 +115,6 @@ export function Modal({
|
|||||||
"modal--large": large,
|
"modal--large": large,
|
||||||
})}
|
})}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby={title}
|
|
||||||
aria-describedby={description}
|
aria-describedby={description}
|
||||||
ref={modalContentRef}
|
ref={modalContentRef}
|
||||||
data-hydra-dialog
|
data-hydra-dialog
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
background-color: globals.$dark-background-color;
|
background-color: globals.$dark-background-color;
|
||||||
border: 1px solid globals.$border-color;
|
border: 1px solid globals.$border-color;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-height: 300px;
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
@@ -92,23 +92,8 @@ export function SearchDropdown({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [visible, onClose, searchContainerRef]);
|
}, [visible, onClose, searchContainerRef]);
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
|
||||||
(
|
|
||||||
type: "history" | "suggestion",
|
|
||||||
item: SearchHistoryEntry | SearchSuggestion
|
|
||||||
) => {
|
|
||||||
if (type === "history") {
|
|
||||||
onSelectHistory((item as SearchHistoryEntry).query);
|
|
||||||
} else {
|
|
||||||
onSelectSuggestion(item as SearchSuggestion);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onSelectHistory, onSelectSuggestion]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
const totalItems = historyItems.length + suggestions.length;
|
|
||||||
const hasHistory = historyItems.length > 0;
|
const hasHistory = historyItems.length > 0;
|
||||||
const hasSuggestions = suggestions.length > 0;
|
const hasSuggestions = suggestions.length > 0;
|
||||||
|
|
||||||
@@ -158,7 +143,7 @@ export function SearchDropdown({
|
|||||||
activeIndex === getItemIndex("history", index),
|
activeIndex === getItemIndex("history", index),
|
||||||
})}
|
})}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleItemClick("history", item)}
|
onClick={() => onSelectHistory(item.query)}
|
||||||
>
|
>
|
||||||
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
||||||
<span className="search-dropdown__item-text">
|
<span className="search-dropdown__item-text">
|
||||||
@@ -200,7 +185,7 @@ export function SearchDropdown({
|
|||||||
activeIndex === getItemIndex("suggestion", index),
|
activeIndex === getItemIndex("suggestion", index),
|
||||||
})}
|
})}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleItemClick("suggestion", item)}
|
onClick={() => onSelectSuggestion(item)}
|
||||||
>
|
>
|
||||||
{item.iconUrl ? (
|
{item.iconUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -227,13 +212,6 @@ export function SearchDropdown({
|
|||||||
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
||||||
<div className="search-dropdown__loading">{t("loading")}</div>
|
<div className="search-dropdown__loading">{t("loading")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingSuggestions &&
|
|
||||||
!hasHistory &&
|
|
||||||
!hasSuggestions &&
|
|
||||||
totalItems === 0 && (
|
|
||||||
<div className="search-dropdown__empty">{t("no_results")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cn from "classnames";
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GameContextMenu } from "..";
|
import { GameContextMenu } from "..";
|
||||||
|
import { useAppSelector } from "@renderer/hooks";
|
||||||
|
|
||||||
interface SidebarGameItemProps {
|
interface SidebarGameItemProps {
|
||||||
game: LibraryGame;
|
game: LibraryGame;
|
||||||
@@ -18,6 +19,9 @@ export function SidebarGameItem({
|
|||||||
getGameTitle,
|
getGameTitle,
|
||||||
}: Readonly<SidebarGameItemProps>) {
|
}: Readonly<SidebarGameItemProps>) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const userPreferences = useAppSelector(
|
||||||
|
(state) => state.userPreferences.value
|
||||||
|
);
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
@@ -81,7 +85,8 @@ export function SidebarGameItem({
|
|||||||
{getGameTitle(game)}
|
{getGameTitle(game)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{(game.newDownloadOptionsCount ?? 0) > 0 && (
|
{userPreferences?.enableNewDownloadOptionsBadges !== false &&
|
||||||
|
(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||||
<span className="sidebar__game-badge">
|
<span className="sidebar__game-badge">
|
||||||
+{game.newDownloadOptionsCount}
|
+{game.newDownloadOptionsCount}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__friends-button {
|
&__notification-button {
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__friends-button-badge {
|
&__notification-button-badge {
|
||||||
background-color: globals.$success-color;
|
background-color: globals.$success-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__game-running-icon {
|
&__game-running-icon {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PeopleIcon } from "@primer/octicons-react";
|
import { BellIcon } from "@primer/octicons-react";
|
||||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
import { Avatar } from "../avatar/avatar";
|
import { Avatar } from "../avatar/avatar";
|
||||||
import { AuthPage } from "@shared";
|
import { AuthPage } from "@shared";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
|
import type { NotificationCountResponse } from "@types";
|
||||||
import "./sidebar-profile.scss";
|
import "./sidebar-profile.scss";
|
||||||
|
|
||||||
export function SidebarProfile() {
|
export function SidebarProfile() {
|
||||||
@@ -14,11 +15,87 @@ export function SidebarProfile() {
|
|||||||
|
|
||||||
const { t } = useTranslation("sidebar");
|
const { t } = useTranslation("sidebar");
|
||||||
|
|
||||||
const { userDetails, friendRequestCount, showFriendsModal } =
|
const { userDetails } = useUserDetails();
|
||||||
useUserDetails();
|
|
||||||
|
|
||||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||||
|
|
||||||
|
const [notificationCount, setNotificationCount] = useState(0);
|
||||||
|
const apiNotificationCountRef = useRef(0);
|
||||||
|
const hasFetchedInitialCount = useRef(false);
|
||||||
|
|
||||||
|
const fetchLocalNotificationCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const localCount = await window.electron.getLocalNotificationsCount();
|
||||||
|
setNotificationCount(localCount + apiNotificationCountRef.current);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to fetch local notification count", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchApiNotificationCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||||
|
"/profile/notifications/count",
|
||||||
|
{ needsAuth: true }
|
||||||
|
);
|
||||||
|
apiNotificationCountRef.current = response.count;
|
||||||
|
} catch {
|
||||||
|
// Ignore API errors
|
||||||
|
}
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
|
// Initial fetch on mount (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
|
// Fetch API count when user logs in (only if not already fetched)
|
||||||
|
useEffect(() => {
|
||||||
|
if (userDetails && !hasFetchedInitialCount.current) {
|
||||||
|
hasFetchedInitialCount.current = true;
|
||||||
|
fetchApiNotificationCount();
|
||||||
|
} else if (!userDetails) {
|
||||||
|
hasFetchedInitialCount.current = false;
|
||||||
|
apiNotificationCountRef.current = 0;
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
}
|
||||||
|
}, [userDetails, fetchApiNotificationCount, fetchLocalNotificationCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNotificationsChange = () => {
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
"notificationsChanged",
|
||||||
|
handleNotificationsChange
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onSyncNotificationCount(
|
||||||
|
(notification) => {
|
||||||
|
apiNotificationCountRef.current = notification.notificationCount;
|
||||||
|
fetchLocalNotificationCount();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [fetchLocalNotificationCount]);
|
||||||
|
|
||||||
const handleProfileClick = () => {
|
const handleProfileClick = () => {
|
||||||
if (userDetails === null) {
|
if (userDetails === null) {
|
||||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||||
@@ -28,28 +105,24 @@ export function SidebarProfile() {
|
|||||||
navigate(`/profile/${userDetails.id}`);
|
navigate(`/profile/${userDetails.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const friendsButton = useMemo(() => {
|
const notificationsButton = useMemo(() => {
|
||||||
if (!userDetails) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sidebar-profile__friends-button"
|
className="sidebar-profile__notification-button"
|
||||||
onClick={() =>
|
onClick={() => navigate("/notifications")}
|
||||||
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
|
title={t("notifications")}
|
||||||
}
|
|
||||||
title={t("friends")}
|
|
||||||
>
|
>
|
||||||
{friendRequestCount > 0 && (
|
{notificationCount > 0 && (
|
||||||
<small className="sidebar-profile__friends-button-badge">
|
<small className="sidebar-profile__notification-button-badge">
|
||||||
{friendRequestCount > 99 ? "99+" : friendRequestCount}
|
{notificationCount > 99 ? "99+" : notificationCount}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PeopleIcon size={16} />
|
<BellIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}, [userDetails, t, friendRequestCount, showFriendsModal]);
|
}, [t, notificationCount, navigate]);
|
||||||
|
|
||||||
const gameRunningDetails = () => {
|
const gameRunningDetails = () => {
|
||||||
if (!userDetails || !gameRunning) return null;
|
if (!userDetails || !gameRunning) return null;
|
||||||
@@ -98,7 +171,7 @@ export function SidebarProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{friendsButton}
|
{notificationsButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
|
|
||||||
export const VERSION_CODENAME = "Supernova";
|
export const VERSION_CODENAME = "Harbinger";
|
||||||
|
|
||||||
export const DOWNLOADER_NAME = {
|
export const DOWNLOADER_NAME = {
|
||||||
[Downloader.RealDebrid]: "Real-Debrid",
|
[Downloader.RealDebrid]: "Real-Debrid",
|
||||||
[Downloader.Torrent]: "Torrent",
|
[Downloader.Torrent]: "Torrent",
|
||||||
[Downloader.Gofile]: "Gofile",
|
[Downloader.Gofile]: "Gofile",
|
||||||
[Downloader.PixelDrain]: "PixelDrain",
|
[Downloader.PixelDrain]: "PixelDrain",
|
||||||
[Downloader.Qiwi]: "Qiwi",
|
|
||||||
[Downloader.Datanodes]: "Datanodes",
|
[Downloader.Datanodes]: "Datanodes",
|
||||||
[Downloader.Mediafire]: "Mediafire",
|
[Downloader.Mediafire]: "Mediafire",
|
||||||
|
[Downloader.Buzzheavier]: "Buzzheavier",
|
||||||
|
[Downloader.FuckingFast]: "FuckingFast",
|
||||||
[Downloader.TorBox]: "TorBox",
|
[Downloader.TorBox]: "TorBox",
|
||||||
[Downloader.Hydra]: "Nimbus",
|
[Downloader.Hydra]: "Nimbus",
|
||||||
|
[Downloader.VikingFile]: "VikingFile",
|
||||||
|
[Downloader.Rootz]: "Rootz",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||||
|
|||||||
@@ -225,6 +225,16 @@ export function GameDetailsContextProvider({
|
|||||||
};
|
};
|
||||||
}, [game?.id, isGameRunning, updateGame]);
|
}, [game?.id, isGameRunning, updateGame]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
|
||||||
|
updateGame();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [updateGame]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (ev: Event) => {
|
const handler = (ev: Event) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
23
src/renderer/src/declaration.d.ts
vendored
23
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,7 @@ import type {
|
|||||||
GameStats,
|
GameStats,
|
||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
GameArtifact,
|
GameArtifact,
|
||||||
LudusaviBackup,
|
LudusaviBackup,
|
||||||
UserAchievement,
|
UserAchievement,
|
||||||
@@ -31,6 +32,7 @@ import type {
|
|||||||
Game,
|
Game,
|
||||||
DiskUsage,
|
DiskUsage,
|
||||||
DownloadSource,
|
DownloadSource,
|
||||||
|
LocalNotification,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
|
||||||
@@ -165,6 +167,10 @@ declare global {
|
|||||||
getLibrary: () => Promise<LibraryGame[]>;
|
getLibrary: () => Promise<LibraryGame[]>;
|
||||||
refreshLibraryAssets: () => Promise<void>;
|
refreshLibraryAssets: () => Promise<void>;
|
||||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||||
|
getGameInstallerActionType: (
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string
|
||||||
|
) => Promise<"install" | "open-folder">;
|
||||||
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
openGame: (
|
openGame: (
|
||||||
@@ -205,6 +211,10 @@ declare global {
|
|||||||
minimized: boolean;
|
minimized: boolean;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
|
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||||
|
scanInstalledGames: () => Promise<{
|
||||||
|
foundGames: { title: string; executablePath: string }[];
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
onExtractionComplete: (
|
onExtractionComplete: (
|
||||||
cb: (shop: GameShop, objectId: string) => void
|
cb: (shop: GameShop, objectId: string) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
@@ -387,10 +397,12 @@ declare global {
|
|||||||
processProfileImage: (
|
processProfileImage: (
|
||||||
path: string
|
path: string
|
||||||
) => Promise<{ imagePath: string; mimeType: string }>;
|
) => Promise<{ imagePath: string; mimeType: string }>;
|
||||||
syncFriendRequests: () => Promise<void>;
|
|
||||||
onSyncFriendRequests: (
|
onSyncFriendRequests: (
|
||||||
cb: (friendRequests: FriendRequestSync) => void
|
cb: (friendRequests: FriendRequestSync) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
onSyncNotificationCount: (
|
||||||
|
cb: (notification: NotificationSync) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
updateFriendRequest: (
|
updateFriendRequest: (
|
||||||
userId: string,
|
userId: string,
|
||||||
action: FriendRequestAction
|
action: FriendRequestAction
|
||||||
@@ -398,6 +410,15 @@ declare global {
|
|||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||||
|
getLocalNotifications: () => Promise<LocalNotification[]>;
|
||||||
|
getLocalNotificationsCount: () => Promise<number>;
|
||||||
|
markLocalNotificationRead: (id: string) => Promise<void>;
|
||||||
|
markAllLocalNotificationsRead: () => Promise<void>;
|
||||||
|
deleteLocalNotification: (id: string) => Promise<void>;
|
||||||
|
clearAllLocalNotifications: () => Promise<void>;
|
||||||
|
onLocalNotificationCreated: (
|
||||||
|
cb: (notification: LocalNotification) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
onAchievementUnlocked: (
|
onAchievementUnlocked: (
|
||||||
cb: (
|
cb: (
|
||||||
position?: AchievementCustomNotificationPosition,
|
position?: AchievementCustomNotificationPosition,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface DownloadState {
|
|||||||
gameId: string | null;
|
gameId: string | null;
|
||||||
gamesWithDeletionInProgress: string[];
|
gamesWithDeletionInProgress: string[];
|
||||||
extraction: ExtractionInfo | null;
|
extraction: ExtractionInfo | null;
|
||||||
|
peakSpeeds: Record<string, number>;
|
||||||
|
speedHistory: Record<string, number[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: DownloadState = {
|
const initialState: DownloadState = {
|
||||||
@@ -19,6 +21,8 @@ const initialState: DownloadState = {
|
|||||||
gameId: null,
|
gameId: null,
|
||||||
gamesWithDeletionInProgress: [],
|
gamesWithDeletionInProgress: [],
|
||||||
extraction: null,
|
extraction: null,
|
||||||
|
peakSpeeds: {},
|
||||||
|
speedHistory: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadSlice = createSlice({
|
export const downloadSlice = createSlice({
|
||||||
@@ -27,7 +31,33 @@ export const downloadSlice = createSlice({
|
|||||||
reducers: {
|
reducers: {
|
||||||
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
||||||
state.lastPacket = action.payload;
|
state.lastPacket = action.payload;
|
||||||
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
|
|
||||||
|
// Ensure payload exists and has a valid gameId before accessing
|
||||||
|
const payload = action.payload;
|
||||||
|
if (!state.gameId && payload?.gameId) {
|
||||||
|
state.gameId = payload.gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track peak speed and speed history atomically when packet arrives
|
||||||
|
if (payload?.gameId && payload.downloadSpeed != null) {
|
||||||
|
const { gameId, downloadSpeed } = payload;
|
||||||
|
|
||||||
|
// Update peak speed if this is higher
|
||||||
|
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||||
|
if (downloadSpeed > currentPeak) {
|
||||||
|
state.peakSpeeds[gameId] = downloadSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update speed history for chart
|
||||||
|
if (!state.speedHistory[gameId]) {
|
||||||
|
state.speedHistory[gameId] = [];
|
||||||
|
}
|
||||||
|
state.speedHistory[gameId].push(downloadSpeed);
|
||||||
|
// Keep only last 120 entries
|
||||||
|
if (state.speedHistory[gameId].length > 120) {
|
||||||
|
state.speedHistory[gameId].shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearDownload: (state) => {
|
clearDownload: (state) => {
|
||||||
state.lastPacket = null;
|
state.lastPacket = null;
|
||||||
@@ -62,6 +92,20 @@ export const downloadSlice = createSlice({
|
|||||||
clearExtraction: (state) => {
|
clearExtraction: (state) => {
|
||||||
state.extraction = null;
|
state.extraction = null;
|
||||||
},
|
},
|
||||||
|
updatePeakSpeed: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ gameId: string; speed: number }>
|
||||||
|
) => {
|
||||||
|
const { gameId, speed } = action.payload;
|
||||||
|
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||||
|
if (speed > currentPeak) {
|
||||||
|
state.peakSpeeds[gameId] = speed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearPeakSpeed: (state, action: PayloadAction<string>) => {
|
||||||
|
state.peakSpeeds[action.payload] = 0;
|
||||||
|
state.speedHistory[action.payload] = [];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,4 +116,6 @@ export const {
|
|||||||
removeGameFromDeleting,
|
removeGameFromDeleting,
|
||||||
setExtractionProgress,
|
setExtractionProgress,
|
||||||
clearExtraction,
|
clearExtraction,
|
||||||
|
updatePeakSpeed,
|
||||||
|
clearPeakSpeed,
|
||||||
} = downloadSlice.actions;
|
} = downloadSlice.actions;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
import type { FriendRequest, UserDetails } from "@types";
|
import type { FriendRequest, UserDetails } from "@types";
|
||||||
|
|
||||||
export interface UserDetailsState {
|
export interface UserDetailsState {
|
||||||
@@ -7,9 +6,6 @@ export interface UserDetailsState {
|
|||||||
profileBackground: null | string;
|
profileBackground: null | string;
|
||||||
friendRequests: FriendRequest[];
|
friendRequests: FriendRequest[];
|
||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
isFriendsModalVisible: boolean;
|
|
||||||
friendRequetsModalTab: UserFriendModalTab | null;
|
|
||||||
friendModalUserId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserDetailsState = {
|
const initialState: UserDetailsState = {
|
||||||
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
|
|||||||
profileBackground: null,
|
profileBackground: null,
|
||||||
friendRequests: [],
|
friendRequests: [],
|
||||||
friendRequestCount: 0,
|
friendRequestCount: 0,
|
||||||
isFriendsModalVisible: false,
|
|
||||||
friendRequetsModalTab: null,
|
|
||||||
friendModalUserId: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userDetailsSlice = createSlice({
|
export const userDetailsSlice = createSlice({
|
||||||
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
|
|||||||
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
||||||
state.friendRequestCount = action.payload;
|
state.friendRequestCount = action.payload;
|
||||||
},
|
},
|
||||||
setFriendsModalVisible: (
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
|
|
||||||
) => {
|
|
||||||
state.isFriendsModalVisible = true;
|
|
||||||
state.friendRequetsModalTab = action.payload.initialTab;
|
|
||||||
state.friendModalUserId = action.payload.userId;
|
|
||||||
},
|
|
||||||
setFriendsModalHidden: (state) => {
|
|
||||||
state.isFriendsModalVisible = false;
|
|
||||||
state.friendRequetsModalTab = null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +39,4 @@ export const {
|
|||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
setFriendRequests,
|
setFriendRequests,
|
||||||
setFriendRequestCount,
|
setFriendRequestCount,
|
||||||
setFriendsModalVisible,
|
|
||||||
setFriendsModalHidden,
|
|
||||||
} = userDetailsSlice.actions;
|
} = userDetailsSlice.actions;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
setUserDetails,
|
setUserDetails,
|
||||||
setFriendRequests,
|
setFriendRequests,
|
||||||
setFriendsModalVisible,
|
|
||||||
setFriendsModalHidden,
|
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import type {
|
import type {
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
@@ -13,20 +11,12 @@ import type {
|
|||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
|
|
||||||
export function useUserDetails() {
|
export function useUserDetails() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const {
|
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
|
||||||
userDetails,
|
useAppSelector((state) => state.userDetails);
|
||||||
profileBackground,
|
|
||||||
friendRequests,
|
|
||||||
friendRequestCount,
|
|
||||||
isFriendsModalVisible,
|
|
||||||
friendModalUserId,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
} = useAppSelector((state) => state.userDetails);
|
|
||||||
|
|
||||||
const clearUserDetails = useCallback(async () => {
|
const clearUserDetails = useCallback(async () => {
|
||||||
dispatch(setUserDetails(null));
|
dispatch(setUserDetails(null));
|
||||||
@@ -69,6 +59,7 @@ export function useUserDetails() {
|
|||||||
username: userDetails?.username || "",
|
username: userDetails?.username || "",
|
||||||
subscription: userDetails?.subscription || null,
|
subscription: userDetails?.subscription || null,
|
||||||
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
||||||
|
workwondersJwt: userDetails?.workwondersJwt || "",
|
||||||
karma: userDetails?.karma || 0,
|
karma: userDetails?.karma || 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -85,24 +76,11 @@ export function useUserDetails() {
|
|||||||
return window.electron.hydraApi
|
return window.electron.hydraApi
|
||||||
.get<FriendRequest[]>("/profile/friend-requests")
|
.get<FriendRequest[]>("/profile/friend-requests")
|
||||||
.then((friendRequests) => {
|
.then((friendRequests) => {
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
dispatch(setFriendRequests(friendRequests));
|
dispatch(setFriendRequests(friendRequests));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const showFriendsModal = useCallback(
|
|
||||||
(initialTab: UserFriendModalTab, userId: string) => {
|
|
||||||
dispatch(setFriendsModalVisible({ initialTab, userId }));
|
|
||||||
fetchFriendRequests();
|
|
||||||
},
|
|
||||||
[dispatch, fetchFriendRequests]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideFriendsModal = useCallback(() => {
|
|
||||||
dispatch(setFriendsModalHidden());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const sendFriendRequest = useCallback(
|
const sendFriendRequest = useCallback(
|
||||||
async (userId: string) => {
|
async (userId: string) => {
|
||||||
return window.electron.hydraApi
|
return window.electron.hydraApi
|
||||||
@@ -134,7 +112,7 @@ export function useUserDetails() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const undoFriendship = (userId: string) =>
|
const undoFriendship = (userId: string) =>
|
||||||
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
|
window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
|
||||||
|
|
||||||
const blockUser = (userId: string) =>
|
const blockUser = (userId: string) =>
|
||||||
window.electron.hydraApi.post(`/users/${userId}/block`);
|
window.electron.hydraApi.post(`/users/${userId}/block`);
|
||||||
@@ -152,12 +130,7 @@ export function useUserDetails() {
|
|||||||
profileBackground,
|
profileBackground,
|
||||||
friendRequests,
|
friendRequests,
|
||||||
friendRequestCount,
|
friendRequestCount,
|
||||||
friendRequetsModalTab,
|
|
||||||
isFriendsModalVisible,
|
|
||||||
friendModalUserId,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
showFriendsModal,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
signOut,
|
signOut,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import resources from "@locales";
|
|||||||
|
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { addCookieInterceptor } from "./cookies";
|
import { addCookieInterceptor } from "./cookies";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import { levelDBService } from "./services/leveldb.service";
|
import { levelDBService } from "./services/leveldb.service";
|
||||||
import Catalogue from "./pages/catalogue/catalogue";
|
import Catalogue from "./pages/catalogue/catalogue";
|
||||||
import Home from "./pages/home/home";
|
import Home from "./pages/home/home";
|
||||||
@@ -31,10 +32,23 @@ import Profile from "./pages/profile/profile";
|
|||||||
import Achievements from "./pages/achievements/achievements";
|
import Achievements from "./pages/achievements/achievements";
|
||||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||||
import Library from "./pages/library/library";
|
import Library from "./pages/library/library";
|
||||||
|
import Notifications from "./pages/notifications/notifications";
|
||||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||||
|
|
||||||
console.log = logger.log;
|
console.log = logger.log;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
|
||||||
|
integrations: [
|
||||||
|
Sentry.browserTracingIntegration(),
|
||||||
|
Sentry.replayIntegration(),
|
||||||
|
],
|
||||||
|
tracesSampleRate: 0.5,
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 0,
|
||||||
|
release: "hydra-launcher@" + (await window.electron.getVersion()),
|
||||||
|
});
|
||||||
|
|
||||||
const isStaging = await window.electron.isStaging();
|
const isStaging = await window.electron.isStaging();
|
||||||
addCookieInterceptor(isStaging);
|
addCookieInterceptor(isStaging);
|
||||||
|
|
||||||
@@ -76,6 +90,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/profile/:userId" element={<Profile />} />
|
<Route path="/profile/:userId" element={<Profile />} />
|
||||||
<Route path="/achievements" element={<Achievements />} />
|
<Route path="/achievements" element={<Achievements />} />
|
||||||
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
<Route path="/theme-editor" element={<ThemeEditor />} />
|
||||||
|
|||||||
@@ -427,7 +427,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
@@ -509,6 +509,15 @@
|
|||||||
&__simple-menu-btn {
|
&__simple-menu-btn {
|
||||||
padding: calc(globals.$spacing-unit);
|
padding: calc(globals.$spacing-unit);
|
||||||
min-height: unset;
|
min-height: unset;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-action-btn {
|
||||||
|
padding: calc(globals.$spacing-unit);
|
||||||
|
min-height: unset;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
min-width: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__progress-wrapper {
|
&__progress-wrapper {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||||
|
|
||||||
import { Badge, Button } from "@renderer/components";
|
import { Badge, Button, ConfirmationModal } from "@renderer/components";
|
||||||
import {
|
import {
|
||||||
formatDownloadProgress,
|
formatDownloadProgress,
|
||||||
buildGameDetailsPath,
|
buildGameDetailsPath,
|
||||||
@@ -32,12 +32,12 @@ import {
|
|||||||
FileDirectoryIcon,
|
FileDirectoryIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
ThreeBarsIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UnlinkIcon,
|
UnlinkIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
GraphIcon,
|
GraphIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
|
import { MoreVertical, Folder } from "lucide-react";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
|
|
||||||
interface AnimatedPercentageProps {
|
interface AnimatedPercentageProps {
|
||||||
@@ -219,7 +219,7 @@ interface HeroDownloadViewProps {
|
|||||||
calculateETA: () => string;
|
calculateETA: () => string;
|
||||||
pauseDownload: (shop: GameShop, objectId: string) => void;
|
pauseDownload: (shop: GameShop, objectId: string) => void;
|
||||||
resumeDownload: (shop: GameShop, objectId: string) => void;
|
resumeDownload: (shop: GameShop, objectId: string) => void;
|
||||||
cancelDownload: (shop: GameShop, objectId: string) => void;
|
onCancelClick: (shop: GameShop, objectId: string) => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ function HeroDownloadView({
|
|||||||
calculateETA,
|
calculateETA,
|
||||||
pauseDownload,
|
pauseDownload,
|
||||||
resumeDownload,
|
resumeDownload,
|
||||||
cancelDownload,
|
onCancelClick,
|
||||||
t,
|
t,
|
||||||
}: Readonly<HeroDownloadViewProps>) {
|
}: Readonly<HeroDownloadViewProps>) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -353,7 +353,7 @@ function HeroDownloadView({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => cancelDownload(game.shop, game.objectId)}
|
onClick={() => onCancelClick(game.shop, game.objectId)}
|
||||||
className="download-group__glass-btn"
|
className="download-group__glass-btn"
|
||||||
>
|
>
|
||||||
<XCircleIcon size={14} />
|
<XCircleIcon size={14} />
|
||||||
@@ -412,10 +412,12 @@ function HeroDownloadView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{game.download?.downloader && (
|
{game.download?.downloader !== undefined && (
|
||||||
<div className="download-group__stat-item">
|
<div className="download-group__stat-item">
|
||||||
<div className="download-group__stat-content">
|
<div className="download-group__stat-content">
|
||||||
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
|
<Badge>
|
||||||
|
{DOWNLOADER_NAME[Number(game.download.downloader)]}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -450,6 +452,7 @@ export function DownloadGroup({
|
|||||||
seedingStatus,
|
seedingStatus,
|
||||||
}: Readonly<DownloadGroupProps>) {
|
}: Readonly<DownloadGroupProps>) {
|
||||||
const { t } = useTranslation("downloads");
|
const { t } = useTranslation("downloads");
|
||||||
|
const { t: tGameDetails } = useTranslation("game_details");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const userPreferences = useAppSelector(
|
const userPreferences = useAppSelector(
|
||||||
@@ -512,14 +515,25 @@ export function DownloadGroup({
|
|||||||
|
|
||||||
const { formatDistance } = useDate();
|
const { formatDistance } = useDate();
|
||||||
|
|
||||||
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
|
// Get speed history and peak speeds from Redux (centralized state)
|
||||||
const speedHistoryRef = useRef<Record<string, number[]>>({});
|
const speedHistory = useAppSelector((state) => state.download.speedHistory);
|
||||||
|
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
|
||||||
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
|
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
|
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
const [cancelModalVisible, setCancelModalVisible] = useState(false);
|
||||||
|
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [gameActionTypes, setGameActionTypes] = useState<
|
||||||
|
Record<string, "install" | "open-folder">
|
||||||
|
>({});
|
||||||
|
|
||||||
const extractDominantColor = useCallback(
|
const extractDominantColor = useCallback(
|
||||||
async (imageUrl: string, gameId: string) => {
|
async (imageUrl: string, gameId: string) => {
|
||||||
@@ -576,68 +590,8 @@ export function DownloadGroup({
|
|||||||
});
|
});
|
||||||
}, [library, lastPacket?.gameId]);
|
}, [library, lastPacket?.gameId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
|
||||||
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
|
// No local effect needed - data is updated atomically when packets arrive
|
||||||
const gameId = lastPacket.gameId;
|
|
||||||
|
|
||||||
const currentPeak = peakSpeeds[gameId] || 0;
|
|
||||||
if (lastPacket.downloadSpeed > currentPeak) {
|
|
||||||
setPeakSpeeds((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[gameId]: lastPacket.downloadSpeed,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!speedHistoryRef.current[gameId]) {
|
|
||||||
speedHistoryRef.current[gameId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
|
|
||||||
|
|
||||||
if (speedHistoryRef.current[gameId].length > 120) {
|
|
||||||
speedHistoryRef.current[gameId].shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const game of library) {
|
|
||||||
if (
|
|
||||||
game.download &&
|
|
||||||
game.download.progress < 0.01 &&
|
|
||||||
game.download.status !== "paused"
|
|
||||||
) {
|
|
||||||
// Fresh download - clear any old data
|
|
||||||
if (speedHistoryRef.current[game.id]?.length > 0) {
|
|
||||||
speedHistoryRef.current[game.id] = [];
|
|
||||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [library]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeouts: NodeJS.Timeout[] = [];
|
|
||||||
|
|
||||||
for (const game of library) {
|
|
||||||
if (
|
|
||||||
game.download?.progress === 1 &&
|
|
||||||
speedHistoryRef.current[game.id]?.length > 0
|
|
||||||
) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
speedHistoryRef.current[game.id] = [];
|
|
||||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
|
||||||
}, 10_000);
|
|
||||||
timeouts.push(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
for (const timeout of timeouts) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [library]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (library.length > 0 && title === t("download_in_progress")) {
|
if (library.length > 0 && title === t("download_in_progress")) {
|
||||||
@@ -670,11 +624,18 @@ export function DownloadGroup({
|
|||||||
const download = game.download!;
|
const download = game.download!;
|
||||||
const isGameDownloading = isGameDownloadingMap[game.id];
|
const isGameDownloading = isGameDownloadingMap[game.id];
|
||||||
|
|
||||||
if (download.fileSize != null) return formatBytes(download.fileSize);
|
// Check lastPacket first for most up-to-date size during active downloads
|
||||||
|
if (
|
||||||
if (lastPacket?.download.fileSize && isGameDownloading)
|
isGameDownloading &&
|
||||||
|
lastPacket?.download.fileSize &&
|
||||||
|
lastPacket.download.fileSize > 0
|
||||||
|
)
|
||||||
return formatBytes(lastPacket.download.fileSize);
|
return formatBytes(lastPacket.download.fileSize);
|
||||||
|
|
||||||
|
// Then check the stored download size (must be > 0 to be valid)
|
||||||
|
if (download.fileSize != null && download.fileSize > 0)
|
||||||
|
return formatBytes(download.fileSize);
|
||||||
|
|
||||||
return "N/A";
|
return "N/A";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -708,6 +669,27 @@ export function DownloadGroup({
|
|||||||
[updateLibrary]
|
[updateLibrary]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
|
||||||
|
setGameToCancelShop(shop);
|
||||||
|
setGameToCancelObjectId(objectId);
|
||||||
|
setCancelModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmCancel = useCallback(async () => {
|
||||||
|
if (gameToCancelShop && gameToCancelObjectId) {
|
||||||
|
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
|
||||||
|
}
|
||||||
|
setCancelModalVisible(false);
|
||||||
|
setGameToCancelShop(null);
|
||||||
|
setGameToCancelObjectId(null);
|
||||||
|
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
|
||||||
|
|
||||||
|
const handleCancelModalClose = useCallback(() => {
|
||||||
|
setCancelModalVisible(false);
|
||||||
|
setGameToCancelShop(null);
|
||||||
|
setGameToCancelObjectId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
||||||
const download = lastPacket?.download;
|
const download = lastPacket?.download;
|
||||||
const isGameDownloading = isGameDownloadingMap[game.id];
|
const isGameDownloading = isGameDownloadingMap[game.id];
|
||||||
@@ -716,14 +698,6 @@ export function DownloadGroup({
|
|||||||
|
|
||||||
if (game.download?.progress === 1) {
|
if (game.download?.progress === 1) {
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
|
||||||
label: t("install"),
|
|
||||||
disabled: deleting,
|
|
||||||
onClick: () => {
|
|
||||||
openGameInstaller(game.shop, game.objectId);
|
|
||||||
},
|
|
||||||
icon: <DownloadIcon />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t("extract"),
|
label: t("extract"),
|
||||||
disabled: game.download.extracting,
|
disabled: game.download.extracting,
|
||||||
@@ -778,7 +752,7 @@ export function DownloadGroup({
|
|||||||
{
|
{
|
||||||
label: t("cancel"),
|
label: t("cancel"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
cancelDownload(game.shop, game.objectId);
|
handleCancelClick(game.shop, game.objectId);
|
||||||
},
|
},
|
||||||
icon: <XCircleIcon />,
|
icon: <XCircleIcon />,
|
||||||
},
|
},
|
||||||
@@ -803,7 +777,7 @@ export function DownloadGroup({
|
|||||||
{
|
{
|
||||||
label: t("cancel"),
|
label: t("cancel"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
cancelDownload(game.shop, game.objectId);
|
handleCancelClick(game.shop, game.objectId);
|
||||||
},
|
},
|
||||||
icon: <XCircleIcon />,
|
icon: <XCircleIcon />,
|
||||||
},
|
},
|
||||||
@@ -827,6 +801,37 @@ export function DownloadGroup({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch action types for completed games
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchActionTypes = async () => {
|
||||||
|
const completedGames = library.filter(
|
||||||
|
(game) => game.download?.progress === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionTypesPromises = completedGames.map(async (game) => {
|
||||||
|
try {
|
||||||
|
const actionType = await window.electron.getGameInstallerActionType(
|
||||||
|
game.shop,
|
||||||
|
game.objectId
|
||||||
|
);
|
||||||
|
return { gameId: game.id, actionType };
|
||||||
|
} catch {
|
||||||
|
return { gameId: game.id, actionType: "open-folder" as const };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(actionTypesPromises);
|
||||||
|
const newActionTypes: Record<string, "install" | "open-folder"> = {};
|
||||||
|
results.forEach(({ gameId, actionType }) => {
|
||||||
|
newActionTypes[gameId] = actionType;
|
||||||
|
});
|
||||||
|
|
||||||
|
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchActionTypes();
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
if (!library.length) return null;
|
if (!library.length) return null;
|
||||||
|
|
||||||
const isDownloadingGroup = title === t("download_in_progress");
|
const isDownloadingGroup = title === t("download_in_progress");
|
||||||
@@ -842,7 +847,14 @@ export function DownloadGroup({
|
|||||||
? (lastPacket?.downloadSpeed ?? 0)
|
? (lastPacket?.downloadSpeed ?? 0)
|
||||||
: 0;
|
: 0;
|
||||||
const finalDownloadSize = getFinalDownloadSize(game);
|
const finalDownloadSize = getFinalDownloadSize(game);
|
||||||
const peakSpeed = peakSpeeds[game.id] || 0;
|
// Use lastPacket.gameId for lookup since that's the key used to store the data
|
||||||
|
// Fall back to game.id if lastPacket is not available
|
||||||
|
const dataKey = lastPacket?.gameId ?? game.id;
|
||||||
|
const gameSpeedHistory = speedHistory[dataKey] ?? [];
|
||||||
|
const storedPeak = peakSpeeds[dataKey];
|
||||||
|
// Use stored peak if available and > 0, otherwise use current speed as initial value
|
||||||
|
const peakSpeed =
|
||||||
|
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
|
||||||
|
|
||||||
let currentProgress = game.download?.progress || 0;
|
let currentProgress = game.download?.progress || 0;
|
||||||
if (isGameExtracting) {
|
if (isGameExtracting) {
|
||||||
@@ -854,6 +866,16 @@ export function DownloadGroup({
|
|||||||
const dominantColor = dominantColors[game.id] || "#fff";
|
const dominantColor = dominantColors[game.id] || "#fff";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={cancelModalVisible}
|
||||||
|
title={t("cancel_download")}
|
||||||
|
descriptionText={t("cancel_download_description")}
|
||||||
|
confirmButtonLabel={t("yes_cancel")}
|
||||||
|
cancelButtonLabel={t("keep_downloading")}
|
||||||
|
onConfirm={handleConfirmCancel}
|
||||||
|
onClose={handleCancelModalClose}
|
||||||
|
/>
|
||||||
<HeroDownloadView
|
<HeroDownloadView
|
||||||
game={game}
|
game={game}
|
||||||
isGameDownloading={isGameDownloading}
|
isGameDownloading={isGameDownloading}
|
||||||
@@ -864,18 +886,29 @@ export function DownloadGroup({
|
|||||||
currentProgress={currentProgress}
|
currentProgress={currentProgress}
|
||||||
dominantColor={dominantColor}
|
dominantColor={dominantColor}
|
||||||
lastPacket={lastPacket}
|
lastPacket={lastPacket}
|
||||||
speedHistory={speedHistoryRef.current[game.id] || []}
|
speedHistory={gameSpeedHistory}
|
||||||
formatSpeed={formatSpeed}
|
formatSpeed={formatSpeed}
|
||||||
calculateETA={calculateETA}
|
calculateETA={calculateETA}
|
||||||
pauseDownload={pauseDownload}
|
pauseDownload={pauseDownload}
|
||||||
resumeDownload={resumeDownload}
|
resumeDownload={resumeDownload}
|
||||||
cancelDownload={cancelDownload}
|
onCancelClick={handleCancelClick}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={cancelModalVisible}
|
||||||
|
title={t("cancel_download")}
|
||||||
|
descriptionText={t("cancel_download_description")}
|
||||||
|
confirmButtonLabel={t("yes_cancel")}
|
||||||
|
cancelButtonLabel={t("keep_downloading")}
|
||||||
|
onConfirm={handleConfirmCancel}
|
||||||
|
onClose={handleCancelModalClose}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
|
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
|
||||||
>
|
>
|
||||||
@@ -904,11 +937,15 @@ export function DownloadGroup({
|
|||||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||||
className="download-group__simple-title-button"
|
className="download-group__simple-title-button"
|
||||||
>
|
>
|
||||||
<h3 className="download-group__simple-title">{game.title}</h3>
|
<h3 className="download-group__simple-title">
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
</button>
|
</button>
|
||||||
<div className="download-group__simple-meta">
|
<div className="download-group__simple-meta">
|
||||||
<div className="download-group__simple-meta-row">
|
<div className="download-group__simple-meta-row">
|
||||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
<Badge>
|
||||||
|
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="download-group__simple-meta-row">
|
<div className="download-group__simple-meta-row">
|
||||||
{extraction?.visibleId === game.id ? (
|
{extraction?.visibleId === game.id ? (
|
||||||
@@ -949,16 +986,35 @@ export function DownloadGroup({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="download-group__simple-actions">
|
<div className="download-group__simple-actions">
|
||||||
{game.download?.progress === 1 && (
|
{game.download?.progress === 1 &&
|
||||||
|
(() => {
|
||||||
|
const actionType =
|
||||||
|
gameActionTypes[game.id] || "open-folder";
|
||||||
|
const isInstall = actionType === "install";
|
||||||
|
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
theme="primary"
|
theme="primary"
|
||||||
onClick={() => openGameInstaller(game.shop, game.objectId)}
|
onClick={() =>
|
||||||
|
openGameInstaller(game.shop, game.objectId)
|
||||||
|
}
|
||||||
disabled={isGameDeleting(game.id)}
|
disabled={isGameDeleting(game.id)}
|
||||||
className="download-group__simple-menu-btn"
|
className="download-group__simple-action-btn"
|
||||||
>
|
>
|
||||||
<PlayIcon size={16} />
|
{isInstall ? (
|
||||||
</Button>
|
<>
|
||||||
|
<DownloadIcon size={16} />
|
||||||
|
{t("install")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Folder size={16} />
|
||||||
|
{tGameDetails("open_folder")}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{isQueuedGroup && game.download?.progress !== 1 && (
|
{isQueuedGroup && game.download?.progress !== 1 && (
|
||||||
<Button
|
<Button
|
||||||
theme="primary"
|
theme="primary"
|
||||||
@@ -974,7 +1030,7 @@ export function DownloadGroup({
|
|||||||
theme="outline"
|
theme="outline"
|
||||||
className="download-group__simple-menu-btn"
|
className="download-group__simple-menu-btn"
|
||||||
>
|
>
|
||||||
<ThreeBarsIcon />
|
<MoreVertical size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -983,5 +1039,6 @@ export function DownloadGroup({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useContext, useEffect, useMemo, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { PencilIcon } from "@primer/octicons-react";
|
import { PencilIcon } from "@primer/octicons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { HeroPanel } from "./hero";
|
import { HeroPanel } from "./hero";
|
||||||
import { DescriptionHeader } from "./description-header/description-header";
|
import { DescriptionHeader } from "./description-header/description-header";
|
||||||
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
|
|||||||
|
|
||||||
export function GameDetailsContent() {
|
export function GameDetailsContent() {
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const reviewsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
objectId,
|
objectId,
|
||||||
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
|
|||||||
getGameArtifacts();
|
getGameArtifacts();
|
||||||
}, [getGameArtifacts]);
|
}, [getGameArtifacts]);
|
||||||
|
|
||||||
|
// Scroll to reviews section if reviews=true in URL
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldScrollToReviews = searchParams.get("reviews") === "true";
|
||||||
|
if (shouldScrollToReviews && reviewsRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [searchParams, objectId]);
|
||||||
|
|
||||||
const isCustomGame = game?.shop === "custom";
|
const isCustomGame = game?.shop === "custom";
|
||||||
|
|
||||||
const heroImage = isCustomGame
|
const heroImage = isCustomGame
|
||||||
@@ -229,6 +242,7 @@ export function GameDetailsContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{shop !== "custom" && shop && objectId && (
|
{shop !== "custom" && shop && objectId && (
|
||||||
|
<div ref={reviewsRef}>
|
||||||
<GameReviews
|
<GameReviews
|
||||||
shop={shop}
|
shop={shop}
|
||||||
objectId={objectId}
|
objectId={objectId}
|
||||||
@@ -238,6 +252,7 @@ export function GameDetailsContent() {
|
|||||||
hasUserReviewed={hasUserReviewed}
|
hasUserReviewed={hasUserReviewed}
|
||||||
onUserReviewedChange={setHasUserReviewed}
|
onUserReviewedChange={setHasUserReviewed}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,23 +19,173 @@
|
|||||||
color: globals.$body-color;
|
color: globals.$body-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__downloaders {
|
&__downloaders-list-wrapper {
|
||||||
display: grid;
|
border: 1px solid globals.$border-color;
|
||||||
gap: globals.$spacing-unit;
|
overflow: hidden;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
background-color: globals.$dark-background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__downloader-option {
|
&__downloaders-list {
|
||||||
position: relative;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&:only-child {
|
&::-webkit-scrollbar {
|
||||||
grid-column: 1 / -1;
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__downloader-icon {
|
&__downloader-item {
|
||||||
position: absolute;
|
display: flex;
|
||||||
left: calc(globals.$spacing-unit * 2);
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 1px solid globals.$border-color;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
color: globals.$body-color;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
height: 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-modal__downloader-name {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings-modal__availability-indicator-wrapper {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__downloader-item-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__check-icon {
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__check-icon-wrapper {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__recommendation-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__availability-indicator-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__availability-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--available {
|
||||||
|
background-color: #22c55e;
|
||||||
|
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--unavailable {
|
||||||
|
background-color: #ef4444;
|
||||||
|
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--not-present {
|
||||||
|
background-color: #6b7280;
|
||||||
|
box-shadow: 0 0 6px rgba(107, 114, 128, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: #eab308;
|
||||||
|
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__availability-indicator--pulsating {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__path-error {
|
&__path-error {
|
||||||
@@ -49,4 +199,17 @@
|
|||||||
&__change-path-button {
|
&__change-path-button {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__loading-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
Link,
|
Link,
|
||||||
Modal,
|
Modal,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@renderer/components";
|
} from "@renderer/components";
|
||||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
import {
|
||||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
DownloadIcon,
|
||||||
|
SyncIcon,
|
||||||
|
CheckCircleFillIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
|
||||||
import type { GameRepack } from "@types";
|
import type { GameRepack } from "@types";
|
||||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||||
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
|
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
import { RealDebridInfoModal } from "./real-debrid-info-modal";
|
||||||
import "./download-settings-modal.scss";
|
import "./download-settings-modal.scss";
|
||||||
|
|
||||||
export interface DownloadSettingsModalProps {
|
export interface DownloadSettingsModalProps {
|
||||||
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
|
|||||||
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
|
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
|
||||||
|
|
||||||
const { isFeatureEnabled, Feature } = useFeature();
|
const { isFeatureEnabled, Feature } = useFeature();
|
||||||
|
|
||||||
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
|
|||||||
}
|
}
|
||||||
}, [visible, checkFolderWritePermission, selectedPath]);
|
}, [visible, checkFolderWritePermission, selectedPath]);
|
||||||
|
|
||||||
const downloaders = useMemo(() => {
|
const downloadOptions = useMemo(() => {
|
||||||
return getDownloadersForUris(repack?.uris ?? []);
|
const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
|
||||||
}, [repack?.uris]);
|
|
||||||
|
const downloaderMap = new Map<
|
||||||
|
Downloader,
|
||||||
|
{ hasAvailable: boolean; hasUnavailable: boolean }
|
||||||
|
>();
|
||||||
|
|
||||||
|
if (repack) {
|
||||||
|
for (const uri of repack.uris) {
|
||||||
|
const uriDownloaders = getDownloadersForUri(uri);
|
||||||
|
const isAvailable = !unavailableUrisSet.has(uri);
|
||||||
|
|
||||||
|
for (const downloader of uriDownloaders) {
|
||||||
|
const existing = downloaderMap.get(downloader);
|
||||||
|
if (existing) {
|
||||||
|
existing.hasAvailable = existing.hasAvailable || isAvailable;
|
||||||
|
existing.hasUnavailable = existing.hasUnavailable || !isAvailable;
|
||||||
|
} else {
|
||||||
|
downloaderMap.set(downloader, {
|
||||||
|
hasAvailable: isAvailable,
|
||||||
|
hasUnavailable: !isAvailable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDownloaders = Object.values(Downloader).filter(
|
||||||
|
(value) => typeof value === "number"
|
||||||
|
) as Downloader[];
|
||||||
|
|
||||||
|
const getDownloaderPriority = (option: {
|
||||||
|
isAvailable: boolean;
|
||||||
|
canHandle: boolean;
|
||||||
|
isAvailableButNotConfigured: boolean;
|
||||||
|
}) => {
|
||||||
|
if (option.isAvailable) return 0;
|
||||||
|
if (option.canHandle && !option.isAvailableButNotConfigured) return 1;
|
||||||
|
if (option.isAvailableButNotConfigured) return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
return allDownloaders
|
||||||
|
.filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus
|
||||||
|
.map((downloader) => {
|
||||||
|
const status = downloaderMap.get(downloader);
|
||||||
|
const canHandle = status !== undefined;
|
||||||
|
const isAvailable = status?.hasAvailable ?? false;
|
||||||
|
|
||||||
|
let isConfigured = true;
|
||||||
|
if (downloader === Downloader.RealDebrid) {
|
||||||
|
isConfigured = !!userPreferences?.realDebridApiToken;
|
||||||
|
} else if (downloader === Downloader.TorBox) {
|
||||||
|
isConfigured = !!userPreferences?.torBoxApiToken;
|
||||||
|
}
|
||||||
|
// } else if (downloader === Downloader.Hydra) {
|
||||||
|
// isConfigured = isFeatureEnabled(Feature.Nimbus);
|
||||||
|
// }
|
||||||
|
|
||||||
|
const isAvailableButNotConfigured =
|
||||||
|
isAvailable && !isConfigured && canHandle;
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloader,
|
||||||
|
isAvailable: isAvailable && isConfigured,
|
||||||
|
canHandle,
|
||||||
|
isAvailableButNotConfigured,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b));
|
||||||
|
}, [
|
||||||
|
repack,
|
||||||
|
userPreferences?.realDebridApiToken,
|
||||||
|
userPreferences?.torBoxApiToken,
|
||||||
|
isFeatureEnabled,
|
||||||
|
Feature,
|
||||||
|
]);
|
||||||
|
|
||||||
const getDefaultDownloader = useCallback(
|
const getDefaultDownloader = useCallback(
|
||||||
(availableDownloaders: Downloader[]) => {
|
(availableDownloaders: Downloader[]) => {
|
||||||
if (availableDownloaders.length === 0) return null;
|
if (availableDownloaders.length === 0) return null;
|
||||||
|
|
||||||
if (availableDownloaders.includes(Downloader.Hydra)) {
|
|
||||||
return Downloader.Hydra;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
||||||
return Downloader.RealDebrid;
|
return Downloader.RealDebrid;
|
||||||
}
|
}
|
||||||
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
|
|||||||
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
|
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredDownloaders = downloaders.filter((downloader) => {
|
const availableDownloaders = downloadOptions
|
||||||
if (downloader === Downloader.RealDebrid)
|
.filter((option) => option.isAvailable)
|
||||||
return userPreferences?.realDebridApiToken;
|
.map((option) => option.downloader);
|
||||||
if (downloader === Downloader.TorBox)
|
|
||||||
return userPreferences?.torBoxApiToken;
|
|
||||||
if (downloader === Downloader.Hydra)
|
|
||||||
return isFeatureEnabled(Feature.Nimbus);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
|
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
|
||||||
}, [
|
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
|
||||||
Feature,
|
|
||||||
isFeatureEnabled,
|
|
||||||
getDefaultDownloader,
|
|
||||||
userPreferences?.downloadsPath,
|
|
||||||
downloaders,
|
|
||||||
userPreferences?.realDebridApiToken,
|
|
||||||
userPreferences?.torBoxApiToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleChooseDownloadsPath = async () => {
|
const handleChooseDownloadsPath = async () => {
|
||||||
const { filePaths } = await window.electron.showOpenDialog({
|
const { filePaths } = await window.electron.showOpenDialog({
|
||||||
@@ -186,35 +252,146 @@ export function DownloadSettingsModal({
|
|||||||
<div className="download-settings-modal__downloads-path-field">
|
<div className="download-settings-modal__downloads-path-field">
|
||||||
<span>{t("downloader")}</span>
|
<span>{t("downloader")}</span>
|
||||||
|
|
||||||
<div className="download-settings-modal__downloaders">
|
<div className="download-settings-modal__downloaders-list-wrapper">
|
||||||
{downloaders.map((downloader) => {
|
<div className="download-settings-modal__downloaders-list">
|
||||||
const shouldDisableButton =
|
{downloadOptions.map((option, index) => {
|
||||||
(downloader === Downloader.RealDebrid &&
|
const isSelected = selectedDownloader === option.downloader;
|
||||||
!userPreferences?.realDebridApiToken) ||
|
const tooltipId = `availability-indicator-${option.downloader}`;
|
||||||
(downloader === Downloader.TorBox &&
|
const isLastItem = index === downloadOptions.length - 1;
|
||||||
!userPreferences?.torBoxApiToken) ||
|
|
||||||
(downloader === Downloader.Hydra &&
|
const Indicator = option.isAvailable ? motion.span : "span";
|
||||||
!isFeatureEnabled(Feature.Nimbus));
|
|
||||||
|
const isDisabled =
|
||||||
|
!option.canHandle ||
|
||||||
|
(!option.isAvailable && !option.isAvailableButNotConfigured);
|
||||||
|
|
||||||
|
const getAvailabilityIndicator = () => {
|
||||||
|
if (option.isAvailable) {
|
||||||
|
return (
|
||||||
|
<Indicator
|
||||||
|
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--available download-settings-modal__availability-indicator--pulsating`}
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
opacity: [1, 0.7, 1],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
data-tooltip-id={tooltipId}
|
||||||
|
data-tooltip-content={t("downloader_online")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.isAvailableButNotConfigured) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
|
||||||
|
data-tooltip-id={tooltipId}
|
||||||
|
data-tooltip-content={t("downloader_not_configured")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.canHandle) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--unavailable`}
|
||||||
|
data-tooltip-id={tooltipId}
|
||||||
|
data-tooltip-content={t("downloader_offline")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<span
|
||||||
key={downloader}
|
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
|
||||||
className="download-settings-modal__downloader-option"
|
data-tooltip-id={tooltipId}
|
||||||
theme={
|
data-tooltip-content={t("downloader_not_available")}
|
||||||
selectedDownloader === downloader ? "primary" : "outline"
|
/>
|
||||||
}
|
);
|
||||||
disabled={shouldDisableButton}
|
};
|
||||||
onClick={() => setSelectedDownloader(downloader)}
|
|
||||||
|
const getRightContent = () => {
|
||||||
|
if (isSelected) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="download-settings-modal__check-icon-wrapper"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{selectedDownloader === downloader && (
|
<CheckCircleFillIcon
|
||||||
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
|
size={16}
|
||||||
)}
|
className="download-settings-modal__check-icon"
|
||||||
{DOWNLOADER_NAME[downloader]}
|
/>
|
||||||
</Button>
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
option.downloader === Downloader.RealDebrid &&
|
||||||
|
option.canHandle
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="download-settings-modal__recommendation-badge">
|
||||||
|
<Badge>{t("recommended")}</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.downloader}
|
||||||
|
className="download-settings-modal__downloader-item-wrapper"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`download-settings-modal__downloader-item ${
|
||||||
|
isSelected
|
||||||
|
? "download-settings-modal__downloader-item--selected"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
isLastItem
|
||||||
|
? "download-settings-modal__downloader-item--last"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
option.downloader === Downloader.RealDebrid &&
|
||||||
|
option.isAvailableButNotConfigured
|
||||||
|
) {
|
||||||
|
setShowRealDebridModal(true);
|
||||||
|
} else {
|
||||||
|
setSelectedDownloader(option.downloader);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="download-settings-modal__downloader-name">
|
||||||
|
{DOWNLOADER_NAME[option.downloader]}
|
||||||
|
</span>
|
||||||
|
<div className="download-settings-modal__availability-indicator-wrapper">
|
||||||
|
{getAvailabilityIndicator()}
|
||||||
|
</div>
|
||||||
|
<Tooltip id={tooltipId} />
|
||||||
|
{getRightContent()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="download-settings-modal__downloads-path-field">
|
<div className="download-settings-modal__downloads-path-field">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
|
|||||||
disabled={
|
disabled={
|
||||||
downloadStarting ||
|
downloadStarting ||
|
||||||
selectedDownloader === null ||
|
selectedDownloader === null ||
|
||||||
!hasWritePermission
|
!hasWritePermission ||
|
||||||
|
downloadOptions.some(
|
||||||
|
(option) =>
|
||||||
|
option.downloader === selectedDownloader &&
|
||||||
|
(option.isAvailableButNotConfigured ||
|
||||||
|
(!option.isAvailable && option.canHandle) ||
|
||||||
|
!option.canHandle)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{downloadStarting ? (
|
||||||
|
<>
|
||||||
|
<SyncIcon className="download-settings-modal__loading-spinner" />
|
||||||
|
{t("loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
{t("download_now")}
|
{t("download_now")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RealDebridInfoModal
|
||||||
|
visible={showRealDebridModal}
|
||||||
|
onClose={() => setShowRealDebridModal(false)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@use "../../../scss/globals.scss";
|
||||||
|
|
||||||
|
.real-debrid-info-modal {
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 2.5);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
color: globals.$body-color;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__create-account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
color: #c0c1c7;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, Link, Modal } from "@renderer/components";
|
||||||
|
import { LinkExternalIcon } from "@primer/octicons-react";
|
||||||
|
import "./real-debrid-info-modal.scss";
|
||||||
|
|
||||||
|
const realDebridReferralId = import.meta.env
|
||||||
|
.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID;
|
||||||
|
|
||||||
|
const REAL_DEBRID_URL = realDebridReferralId
|
||||||
|
? `https://real-debrid.com/?id=${realDebridReferralId}`
|
||||||
|
: "https://real-debrid.com";
|
||||||
|
|
||||||
|
export interface RealDebridInfoModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealDebridInfoModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<RealDebridInfoModalProps>) {
|
||||||
|
const { t } = useTranslation("game_details");
|
||||||
|
const { t: tSettings } = useTranslation("settings");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title={tSettings("enable_real_debrid")}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="real-debrid-info-modal__content">
|
||||||
|
<div className="real-debrid-info-modal__description-container">
|
||||||
|
<p className="real-debrid-info-modal__description">
|
||||||
|
{tSettings("real_debrid_description")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={REAL_DEBRID_URL}
|
||||||
|
className="real-debrid-info-modal__create-account"
|
||||||
|
>
|
||||||
|
<LinkExternalIcon />
|
||||||
|
{tSettings("create_real_debrid_account")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate("/settings?tab=4");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("go_to_settings")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,34 @@
|
|||||||
gap: calc(globals.$spacing-unit * 1);
|
gap: calc(globals.$spacing-unit * 1);
|
||||||
color: globals.$body-color;
|
color: globals.$body-color;
|
||||||
padding: calc(globals.$spacing-unit * 2);
|
padding: calc(globals.$spacing-unit * 2);
|
||||||
|
padding-right: calc(globals.$spacing-unit * 4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__availability-orb {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(globals.$spacing-unit * 1.5);
|
||||||
|
right: calc(globals.$spacing-unit * 1.5);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--online {
|
||||||
|
background-color: #22c55e;
|
||||||
|
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--partial {
|
||||||
|
background-color: #eab308;
|
||||||
|
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--offline {
|
||||||
|
background-color: #ef4444;
|
||||||
|
opacity: 0.7;
|
||||||
|
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__repack-title {
|
&__repack-title {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -21,7 +22,12 @@ import { DownloadSettingsModal } from "./download-settings-modal";
|
|||||||
import { gameDetailsContext } from "@renderer/context";
|
import { gameDetailsContext } from "@renderer/context";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
import { orderBy } from "lodash-es";
|
import { orderBy } from "lodash-es";
|
||||||
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
|
import {
|
||||||
|
useDate,
|
||||||
|
useFeature,
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
} from "@renderer/hooks";
|
||||||
import { clearNewDownloadOptions } from "@renderer/features";
|
import { clearNewDownloadOptions } from "@renderer/features";
|
||||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||||
import { getGameKey } from "@renderer/helpers";
|
import { getGameKey } from "@renderer/helpers";
|
||||||
@@ -70,6 +76,9 @@ export function RepacksModal({
|
|||||||
const { formatDate } = useDate();
|
const { formatDate } = useDate();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const userPreferences = useAppSelector(
|
||||||
|
(state) => state.userPreferences.value
|
||||||
|
);
|
||||||
|
|
||||||
const getHashFromMagnet = (magnet: string) => {
|
const getHashFromMagnet = (magnet: string) => {
|
||||||
if (!magnet || typeof magnet !== "string") {
|
if (!magnet || typeof magnet !== "string") {
|
||||||
@@ -129,10 +138,12 @@ export function RepacksModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (visible) {
|
if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) {
|
||||||
fetchLastCheckTimestamp();
|
fetchLastCheckTimestamp();
|
||||||
|
} else {
|
||||||
|
setIsLoadingTimestamp(false);
|
||||||
}
|
}
|
||||||
}, [visible, repacks]);
|
}, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -175,6 +186,20 @@ export function RepacksModal({
|
|||||||
);
|
);
|
||||||
}, [repacks, hashesInDebrid]);
|
}, [repacks, hashesInDebrid]);
|
||||||
|
|
||||||
|
const getRepackAvailabilityStatus = (
|
||||||
|
repack: GameRepack
|
||||||
|
): "online" | "partial" | "offline" => {
|
||||||
|
const unavailableSet = new Set(repack.unavailableUris ?? []);
|
||||||
|
const availableCount = repack.uris.filter(
|
||||||
|
(uri) => !unavailableSet.has(uri)
|
||||||
|
).length;
|
||||||
|
const unavailableCount = repack.uris.length - availableCount;
|
||||||
|
|
||||||
|
if (unavailableCount === 0) return "online";
|
||||||
|
if (availableCount === 0) return "offline";
|
||||||
|
return "partial";
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const term = filterTerm.trim().toLowerCase();
|
const term = filterTerm.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -353,6 +378,8 @@ export function RepacksModal({
|
|||||||
filteredRepacks.map((repack) => {
|
filteredRepacks.map((repack) => {
|
||||||
const isLastDownloadedOption =
|
const isLastDownloadedOption =
|
||||||
checkIfLastDownloadedOption(repack);
|
checkIfLastDownloadedOption(repack);
|
||||||
|
const availabilityStatus = getRepackAvailabilityStatus(repack);
|
||||||
|
const tooltipId = `availability-orb-${repack.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -361,9 +388,18 @@ export function RepacksModal({
|
|||||||
onClick={() => handleRepackClick(repack)}
|
onClick={() => handleRepackClick(repack)}
|
||||||
className="repacks-modal__repack-button"
|
className="repacks-modal__repack-button"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={`repacks-modal__availability-orb repacks-modal__availability-orb--${availabilityStatus}`}
|
||||||
|
data-tooltip-id={tooltipId}
|
||||||
|
data-tooltip-content={t(`source_${availabilityStatus}`)}
|
||||||
|
/>
|
||||||
|
<Tooltip id={tooltipId} />
|
||||||
|
|
||||||
<p className="repacks-modal__repack-title">
|
<p className="repacks-modal__repack-title">
|
||||||
{repack.title}
|
{repack.title}
|
||||||
{isNewRepack(repack) && (
|
{userPreferences?.enableNewDownloadOptionsBadges !==
|
||||||
|
false &&
|
||||||
|
isNewRepack(repack) && (
|
||||||
<span className="repacks-modal__new-badge">
|
<span className="repacks-modal__new-badge">
|
||||||
{t("new_download_option")}
|
{t("new_download_option")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -221,6 +221,26 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__cover-placeholder {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.08) 0%,
|
||||||
|
rgba(255, 255, 255, 0.04) 50%,
|
||||||
|
rgba(255, 255, 255, 0.08) 100%
|
||||||
|
);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { LibraryGame } from "@types";
|
import { LibraryGame } from "@types";
|
||||||
import { useGameCard } from "@renderer/hooks";
|
import { useGameCard } from "@renderer/hooks";
|
||||||
import { memo } from "react";
|
import { memo, useState } from "react";
|
||||||
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
AlertFillIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
ImageIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
import "./library-game-card.scss";
|
import "./library-game-card.scss";
|
||||||
|
|
||||||
interface LibraryGameCardProps {
|
interface LibraryGameCardProps {
|
||||||
@@ -25,14 +30,9 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
|||||||
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
|
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
|
||||||
useGameCard(game, onContextMenu);
|
useGameCard(game, onContextMenu);
|
||||||
|
|
||||||
const coverImage = (
|
const coverImage = game.coverImageUrl?.replaceAll("\\", "/") ?? "";
|
||||||
game.customIconUrl ??
|
|
||||||
game.coverImageUrl ??
|
const [imageError, setImageError] = useState(false);
|
||||||
game.libraryImageUrl ??
|
|
||||||
game.libraryHeroImageUrl ??
|
|
||||||
game.iconUrl ??
|
|
||||||
""
|
|
||||||
).replaceAll("\\", "/");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -98,12 +98,19 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{imageError || !coverImage ? (
|
||||||
|
<div className="library-game-card__cover-placeholder">
|
||||||
|
<ImageIcon size={48} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={coverImage ?? undefined}
|
src={coverImage}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="library-game-card__game-image"
|
className="library-game-card__game-image"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
105
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
105
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
XIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
PackageIcon,
|
||||||
|
SyncIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
ClockIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useDate } from "@renderer/hooks";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
|
import type { LocalNotification } from "@types";
|
||||||
|
import "./notification-item.scss";
|
||||||
|
|
||||||
|
interface LocalNotificationItemProps {
|
||||||
|
notification: LocalNotification;
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
onMarkAsRead: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalNotificationItem({
|
||||||
|
notification,
|
||||||
|
onDismiss,
|
||||||
|
onMarkAsRead,
|
||||||
|
}: Readonly<LocalNotificationItemProps>) {
|
||||||
|
const { t } = useTranslation("notifications_page");
|
||||||
|
const { formatDistance } = useDate();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!notification.isRead) {
|
||||||
|
onMarkAsRead(notification.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.url) {
|
||||||
|
navigate(notification.url);
|
||||||
|
}
|
||||||
|
}, [notification, onMarkAsRead, navigate]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss(notification.id);
|
||||||
|
},
|
||||||
|
[notification.id, onDismiss]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (notification.type) {
|
||||||
|
case "DOWNLOAD_COMPLETE":
|
||||||
|
return <DownloadIcon size={24} />;
|
||||||
|
case "EXTRACTION_COMPLETE":
|
||||||
|
return <PackageIcon size={24} />;
|
||||||
|
case "UPDATE_AVAILABLE":
|
||||||
|
return <SyncIcon size={24} />;
|
||||||
|
case "ACHIEVEMENT_UNLOCKED":
|
||||||
|
return <TrophyIcon size={24} />;
|
||||||
|
case "SCAN_GAMES_COMPLETE":
|
||||||
|
return <SyncIcon size={24} />;
|
||||||
|
default:
|
||||||
|
return <DownloadIcon size={24} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("notification-item", {
|
||||||
|
"notification-item--unread": !notification.isRead,
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="notification-item__picture">
|
||||||
|
{notification.pictureUrl ? (
|
||||||
|
<img src={notification.pictureUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
getIcon()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-item__content">
|
||||||
|
<span className="notification-item__title">{notification.title}</span>
|
||||||
|
<span className="notification-item__description">
|
||||||
|
{notification.description}
|
||||||
|
</span>
|
||||||
|
<span className="notification-item__time">
|
||||||
|
<ClockIcon size={12} />
|
||||||
|
{formatDistance(new Date(notification.createdAt), new Date())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notification-item__dismiss"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
title={t("dismiss")}
|
||||||
|
>
|
||||||
|
<XIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/renderer/src/pages/notifications/notification-item.scss
Normal file
127
src/renderer/src/pages/notifications/notification-item.scss
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
padding: calc(globals.$spacing-unit * 2);
|
||||||
|
background-color: globals.$dark-background-color;
|
||||||
|
border: 1px solid globals.$border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.4;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
border-left: 3px solid globals.$brand-teal;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item__title {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__picture {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: globals.$background-color;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge-picture {
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: globals.$background-color;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__review-picture {
|
||||||
|
color: #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: globals.$body-font-size;
|
||||||
|
font-weight: 600;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
font-size: globals.$small-font-size;
|
||||||
|
color: globals.$body-color;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
font-size: globals.$small-font-size;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: globals.$spacing-unit;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dismiss {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(globals.$spacing-unit / 2);
|
||||||
|
right: calc(globals.$spacing-unit / 2);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: globals.$body-color;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: calc(globals.$spacing-unit / 2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
228
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
XIcon,
|
||||||
|
PersonIcon,
|
||||||
|
ClockIcon,
|
||||||
|
StarFillIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@renderer/components";
|
||||||
|
import { useDate, useUserDetails } from "@renderer/hooks";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
|
import type { Notification, Badge } from "@types";
|
||||||
|
import "./notification-item.scss";
|
||||||
|
|
||||||
|
const parseNotificationUrl = (notificationUrl: string): string => {
|
||||||
|
const url = new URL(notificationUrl, "http://localhost");
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
const badgeName = url.searchParams.get("name");
|
||||||
|
const gameTitle = url.searchParams.get("title");
|
||||||
|
const showReviews = url.searchParams.get("reviews");
|
||||||
|
|
||||||
|
if (url.pathname === "/profile" && userId) {
|
||||||
|
return `/profile/${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/badges" && badgeName) {
|
||||||
|
return `/badges/${badgeName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/game/")) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (gameTitle) params.set("title", gameTitle);
|
||||||
|
if (showReviews) params.set("reviews", showReviews);
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NotificationItemProps {
|
||||||
|
notification: Notification;
|
||||||
|
badges: Badge[];
|
||||||
|
onDismiss: (id: string) => void;
|
||||||
|
onMarkAsRead: (id: string) => void;
|
||||||
|
onAcceptFriendRequest?: (senderId: string) => void;
|
||||||
|
onRefuseFriendRequest?: (senderId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationItem({
|
||||||
|
notification,
|
||||||
|
badges,
|
||||||
|
onDismiss,
|
||||||
|
onMarkAsRead,
|
||||||
|
onAcceptFriendRequest,
|
||||||
|
onRefuseFriendRequest,
|
||||||
|
}: Readonly<NotificationItemProps>) {
|
||||||
|
const { t } = useTranslation("notifications_page");
|
||||||
|
const { formatDistance } = useDate();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { updateFriendRequestState } = useUserDetails();
|
||||||
|
|
||||||
|
const badge = useMemo(() => {
|
||||||
|
if (notification.type !== "BADGE_RECEIVED") return null;
|
||||||
|
return badges.find((b) => b.name === notification.variables.badgeName);
|
||||||
|
}, [notification, badges]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!notification.isRead) {
|
||||||
|
onMarkAsRead(notification.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.url) {
|
||||||
|
navigate(parseNotificationUrl(notification.url));
|
||||||
|
}
|
||||||
|
}, [notification, onMarkAsRead, navigate]);
|
||||||
|
|
||||||
|
const handleAccept = useCallback(
|
||||||
|
async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const senderId = notification.variables.senderId;
|
||||||
|
if (senderId) {
|
||||||
|
await updateFriendRequestState(senderId, "ACCEPTED");
|
||||||
|
onAcceptFriendRequest?.(senderId);
|
||||||
|
onDismiss(notification.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefuse = useCallback(
|
||||||
|
async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const senderId = notification.variables.senderId;
|
||||||
|
if (senderId) {
|
||||||
|
await updateFriendRequestState(senderId, "REFUSED");
|
||||||
|
onRefuseFriendRequest?.(senderId);
|
||||||
|
onDismiss(notification.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss(notification.id);
|
||||||
|
},
|
||||||
|
[notification.id, onDismiss]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getNotificationContent = () => {
|
||||||
|
switch (notification.type) {
|
||||||
|
case "FRIEND_REQUEST_RECEIVED":
|
||||||
|
return {
|
||||||
|
title: t("friend_request_received_title"),
|
||||||
|
description: t("friend_request_received_description", {
|
||||||
|
displayName: notification.variables.senderDisplayName,
|
||||||
|
}),
|
||||||
|
showActions: true,
|
||||||
|
};
|
||||||
|
case "FRIEND_REQUEST_ACCEPTED":
|
||||||
|
return {
|
||||||
|
title: t("friend_request_accepted_title"),
|
||||||
|
description: t("friend_request_accepted_description", {
|
||||||
|
displayName: notification.variables.accepterDisplayName,
|
||||||
|
}),
|
||||||
|
showActions: false,
|
||||||
|
};
|
||||||
|
case "BADGE_RECEIVED":
|
||||||
|
return {
|
||||||
|
title: t("badge_received_title"),
|
||||||
|
description: badge?.description || notification.variables.badgeName,
|
||||||
|
showActions: false,
|
||||||
|
};
|
||||||
|
case "REVIEW_UPVOTE":
|
||||||
|
return {
|
||||||
|
title: t("review_upvote_title", {
|
||||||
|
gameTitle: notification.variables.gameTitle,
|
||||||
|
}),
|
||||||
|
description: t("review_upvote_description", {
|
||||||
|
count: Number.parseInt(
|
||||||
|
notification.variables.upvoteCount || "1",
|
||||||
|
10
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
showActions: false,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: t("notification"),
|
||||||
|
description: "",
|
||||||
|
showActions: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = getNotificationContent();
|
||||||
|
const isBadge = notification.type === "BADGE_RECEIVED";
|
||||||
|
const isReview = notification.type === "REVIEW_UPVOTE";
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (notification.pictureUrl) {
|
||||||
|
return <img src={notification.pictureUrl} alt="" />;
|
||||||
|
}
|
||||||
|
if (isReview) {
|
||||||
|
return <StarFillIcon size={24} />;
|
||||||
|
}
|
||||||
|
return <PersonIcon size={24} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("notification-item", {
|
||||||
|
"notification-item--unread":
|
||||||
|
!notification.isRead ||
|
||||||
|
notification.type === "FRIEND_REQUEST_RECEIVED",
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("notification-item__picture", {
|
||||||
|
"notification-item__badge-picture": isBadge,
|
||||||
|
"notification-item__review-picture": isReview,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-item__content">
|
||||||
|
<span className="notification-item__title">{content.title}</span>
|
||||||
|
<span className="notification-item__description">
|
||||||
|
{content.description}
|
||||||
|
</span>
|
||||||
|
<span className="notification-item__time">
|
||||||
|
<ClockIcon size={12} />
|
||||||
|
{formatDistance(new Date(notification.createdAt), new Date())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content.showActions &&
|
||||||
|
notification.type === "FRIEND_REQUEST_RECEIVED" && (
|
||||||
|
<div className="notification-item__actions">
|
||||||
|
<Button theme="primary" onClick={handleAccept}>
|
||||||
|
{t("accept")}
|
||||||
|
</Button>
|
||||||
|
<Button theme="outline" onClick={handleRefuse}>
|
||||||
|
{t("refuse")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notification-item__dismiss"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
title={t("dismiss")}
|
||||||
|
>
|
||||||
|
<XIcon size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user