Compare commits

..

81 Commits

Author SHA1 Message Date
Chubby Granny Chaser
7e78a0f9f1 chore: update version to 3.8.1 and enhance translations
Some checks are pending
Build / build (ubuntu-latest) (push) Waiting to run
Build / build (windows-2022) (push) Waiting to run
- Bumped version number in package.json to 3.8.1.
- Added new translation keys for notifications and loading states in Spanish, Portuguese, and Russian.
- Improved UI elements in download group with updated styles for buttons and layout adjustments.
2026-01-11 19:25:11 +00:00
Chubby Granny Chaser
d56cc8695b Merge pull request #1928 from hydralauncher/feat/LBX-367
feat: implement native HTTP downloader option
2026-01-11 18:43:47 +00:00
Moyasee
3f7b9e2a0b fix: merge conflict 2026-01-11 20:42:20 +02:00
Chubby Granny Chaser
3b3926156e Merge pull request #1919 from Stormm232/main
Hungarian Translation of 3.8.0
2026-01-11 18:32:10 +00:00
Chubby Granny Chaser
0089e08ba7 Merge branch 'main' into main 2026-01-11 18:32:04 +00:00
Chubby Granny Chaser
bced12d077 Merge pull request #1934 from hydralauncher/feat/adding-banner-removal
Feat/adding banner removal
2026-01-11 18:31:03 +00:00
Chubby Granny Chaser
f8ba72a0e2 refactor: clean up code formatting and improve readability in DownloadGroup and ProfileHero components
- Adjusted indentation and spacing for better code clarity in DownloadGroup.
- Removed unnecessary blank lines in ProfileHero to streamline the code structure.
- Ensured consistent formatting across both components.
2026-01-11 17:14:24 +00:00
Chubby Granny Chaser
2029f861f6 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-11 17:14:07 +00:00
Chubby Granny Chaser
46e248c62a feat: add banner management features and translations
- Introduced new translations for banner actions including "Change banner", "Replace banner", "Remove banner", and confirmation prompts in English, Spanish, Portuguese, and Russian.
- Updated the UploadBackgroundImageButton component to support banner management with options to change, replace, or remove the banner.
- Implemented a confirmation modal for removing the banner.
- Enhanced user experience with animations for dropdown menus and button interactions.
- Removed deprecated Qiwi downloader support and added Rootz downloader integration.
2026-01-11 17:13:54 +00:00
Moyasee
dba8f9fb22 feat: add disabled hint for HTTP downloader setting during active downloads and update z-index for error message 2026-01-11 19:13:51 +02:00
Moyasee
b565ef7f00 Merge branch 'feat/LBX-367' of https://github.com/hydralauncher/hydra into feat/LBX-367 2026-01-11 17:54:05 +02:00
Moyasee
9298d9aa09 fix: enable native HTTP downloader in settings 2026-01-11 17:50:16 +02:00
Moyase
5b05fc2644 Merge branch 'main' into feat/LBX-367 2026-01-11 16:13:19 +02:00
Moyase
605d064ec0 Merge pull request #1924 from hydralauncher/fix/friends-box-display
feat: add empty state for friends box and new translation key
2026-01-11 16:13:07 +02:00
Moyase
c0956b1bc1 Merge branch 'main' into fix/friends-box-display 2026-01-11 16:06:20 +02:00
Moyasee
2e152d321e refactor: remove HttpMultiLinkDownloader and update download handling logic 2026-01-11 16:00:39 +02:00
Moyasee
a553b049ba Merge branch 'feat/LBX-367' of https://github.com/hydralauncher/hydra into feat/LBX-367 2026-01-11 15:37:13 +02:00
Moyasee
467b27baa3 refactor: remove unused JsMultiLinkDownloader and ensure aria2 spawning on startup 2026-01-11 15:36:16 +02:00
Moyase
4342a4a5d5 Merge branch 'main' into feat/LBX-367 2026-01-11 15:30:17 +02:00
Chubby Granny Chaser
d9d443ee6d Merge pull request #1932 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-e9d8c310be
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
chore(deps): bump @smithy/config-resolver from 4.3.1 to 4.4.5 in the npm_and_yarn group across 1 directory
2026-01-11 02:38:10 +00:00
Chubby Granny Chaser
a912b57ccc Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-e9d8c310be 2026-01-11 02:20:44 +00:00
Moyase
447c146035 Merge pull request #1923 from hydralauncher/fix/archive-extraction
fix: archives with password doesn't extract properly
2026-01-11 04:12:01 +02:00
Moyase
39ff44f9d1 Merge branch 'main' into fix/archive-extraction 2026-01-11 04:09:00 +02:00
Chubby Granny Chaser
dbe101b7df Merge pull request #1927 from Sneezedip/main
Fix translation for hydra_cloud_feature_found (pt-PT)
2026-01-11 02:08:39 +00:00
Moyasee
5e4e03a958 refactor: enhance download management by adding filename resolution and extraction handling in DownloadManage 2026-01-10 20:11:20 +02:00
Moyasee
da0ae54b60 refactor: update cancel download confirmation text and enhance error handling in JsHttpDownloader 2026-01-10 19:47:55 +02:00
Moyasee
562e30eecf refactor: add cancel download confirmation modal and enhance download management in DownloadGroup 2026-01-10 18:49:31 +02:00
dependabot[bot]
e7a62c16fa chore(deps): bump @smithy/config-resolver
Bumps the npm_and_yarn group with 1 update in the / directory: [@smithy/config-resolver](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/config-resolver).


Updates `@smithy/config-resolver` from 4.3.1 to 4.4.5
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/config-resolver/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/config-resolver@4.4.5/packages/config-resolver)

---
updated-dependencies:
- dependency-name: "@smithy/config-resolver"
  dependency-version: 4.4.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-08 23:29:01 +00:00
Moyasee
ed044d797f refactor: streamline download preparation and status handling in DownloadManager 2026-01-07 20:35:43 +02:00
Moyasee
ed3cce160f refactor: adjust file size handling in DownloadManager to ensure accurate download status updates 2026-01-07 17:32:46 +02:00
Moyasee
c67b275657 refactor: improve download initiation and error handling in DownloadManager 2026-01-07 17:26:29 +02:00
Moyasee
a2e866317d refactor: update interruptedDownload type to Download | null for improved type safety 2026-01-06 20:01:15 +02:00
Moyasee
a7c82de4a7 refactor: enhance download management by prioritizing interrupted downloads and improving error logging 2026-01-06 19:59:52 +02:00
Moyasee
027761a1b5 refactor: update cancelDownload method to conditionally delete file based on parameter 2026-01-06 19:18:42 +02:00
Moyasee
ca2f70aede refactor: enhance filename extraction and handling in download services 2026-01-06 18:50:36 +02:00
Moyasee
2b3a8bf6b6 refactor: replace type assertions with non-null assertions for download ID in DownloadManager 2026-01-06 18:11:22 +02:00
Moyasee
81b3ad3612 refactor: update download ID extraction and improve optional chaining in download services 2026-01-06 18:05:05 +02:00
Moyasee
8f477072ba refactor: improve error handling and download path preparation in JsHttpDownloader 2026-01-06 17:56:46 +02:00
Moyasee
569700e85c refactor: streamline download status updates in DownloadManager 2026-01-06 17:47:12 +02:00
Moyasee
4975f2def9 refactor: optimize chunk handling in JsHttpDownloader 2026-01-06 17:42:42 +02:00
Moyasee
77af7509ac feat: implement native HTTP downloader option and enhance download management 2026-01-06 17:41:05 +02:00
Sneezedip
f37ccbb4c0 Fix translation for hydra_cloud_feature_found 2026-01-06 12:33:14 -01:00
Moyasee
feb8d78e01 fix: update password index initialization in tryPassword function for correct behavior 2026-01-04 21:10:37 +02:00
Kiwo.2
44f39d94c4 Added new lines* 2026-01-04 14:07:34 +01:00
Kiwo.2
e618d313b3 Merge branch 'main' of https://github.com/Stormm232/hydra 2026-01-04 13:56:46 +01:00
Kiwo.2
7eec87c192 Added new lines 2026-01-04 13:56:32 +01:00
Moyasee
2ccc93ea61 feat: add empty state for friends box and new translation key 2026-01-04 04:23:59 +02:00
Zamitto
7e7390885e feat: adding ww feedback button
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-03 19:55:48 -03:00
Moyase
64815f4f8d Merge pull request #1910 from hydralauncher/feat/vikingfile-support
feat: VikingFile support and display url availability
2026-01-03 23:42:55 +02:00
Moyasee
4dfdc4d798 chore: remove commented code in DownloadSettingsModal 2026-01-03 23:40:07 +02:00
Moyasee
9bbfab2aff Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 23:35:00 +02:00
Moyasee
01938f8905 refactor: simplify downloader sorting and enhance availability indicators in DownloadSettingsModal 2026-01-03 23:34:19 +02:00
Moyase
f60ad5908d Merge branch 'main' into feat/vikingfile-support 2026-01-03 23:28:17 +02:00
Moyasee
fe6553bcdc chore: remove unused HTTPS import in vikingfile service 2026-01-03 23:23:35 +02:00
Moyasee
87895bb715 refactor: enhance disabled state styling and logic in DownloadSettingsModal 2026-01-03 23:22:40 +02:00
Chubby Granny Chaser
290209f372 chore: remove unnecessary blank lines in RealDebridInfoModal component files 2026-01-03 21:07:35 +00:00
Chubby Granny Chaser
87fcbaa56e chore: bump version to 3.8.0 and update translations for downloader status and notifications 2026-01-03 21:07:09 +00:00
Zamitto
c32ce14630 Merge pull request #1915 from hydralauncher/feat/add-workwonders
feat: add workwonders
2026-01-03 17:19:17 -03:00
Zamitto
e52f10a5ff chore: bump ww version 2026-01-03 17:17:16 -03:00
Moyase
bcdbe31596 Merge pull request #1914 from hydralauncher/fix/friend-request-endpoint
fix: update API endpoint for deleting friend requests in useUserDetai…
2026-01-03 22:08:45 +02:00
Zamitto
7ed514b6ef feat: parse locale before ww init 2026-01-03 16:54:42 -03:00
Moyase
42386ae0b5 Merge branch 'main' into fix/friend-request-endpoint 2026-01-03 21:48:52 +02:00
Moyase
04d8f900a6 Merge pull request #1913 from hydralauncher/fix/friends-and-karma-ui
refactor: remove karma description from translations across multiple …
2026-01-03 21:48:41 +02:00
Zamitto
8b3bcd88b1 feat: add workwonders 2026-01-03 16:42:49 -03:00
Moyasee
b2bffeb2b0 fix: update API endpoint for deleting friend requests in useUserDetails hook 2026-01-03 21:01:39 +02:00
Moyase
0a194eaa29 Merge branch 'main' into fix/friends-and-karma-ui 2026-01-03 20:13:08 +02:00
Moyasee
07c277c033 refactor: remove karma description from translations across multiple languages 2026-01-03 20:11:22 +02:00
Moyasee
345696ad06 Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 19:47:39 +02:00
Moyasee
6c4e8c406f refactor: update HTTP module imports to use node: prefix for consistency 2026-01-03 19:44:39 +02:00
Moyase
c46a1e7848 Merge branch 'main' into feat/vikingfile-support 2026-01-03 19:41:49 +02:00
Moyase
590e09a8c3 Merge pull request #1912 from hydralauncher/fix/notifications-page-ui
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: add unread filter option and enhance notifications UI
2026-01-03 19:41:30 +02:00
Moyasee
c1d7ea27f3 feat: add unread filter option and enhance notifications UI 2026-01-03 19:37:47 +02:00
Chubby Granny Chaser
15dbd3b2ad Merge pull request #1909 from hydralauncher/fix/library-game-covers
fix: library cards not using placeholder and icon as a game cover
2026-01-03 16:19:33 +00:00
Moyasee
4584783f44 refactor: enhance download progress tracking in DownloadManager 2026-01-03 04:47:46 +02:00
Moyasee
765ec70dd0 refactor: streamline downloader logic in DownloadSettingsModal 2026-01-03 01:40:21 +02:00
Moyasee
de483da51c fix: handle download not found exception in HttpDownloader and enforce IPv4 in HTTP agents 2026-01-03 01:08:25 +02:00
Moyasee
2bc0266775 feat: add loading state to download button and enhance UI with spinner 2026-01-03 00:18:07 +02:00
Moyasee
c9729fb3eb chore: update build and release workflows to include MAIN_VITE_NIMBUS_API_URL 2026-01-02 23:59:21 +02:00
Moyasee
9a7ad148e3 fix: use logger for error handling in VikingFile.ts 2026-01-02 23:24:20 +02:00
Moyasee
d929fbaeaa refactor: simplify header assignment in HttpDownloader 2026-01-02 23:23:08 +02:00
Moyasee
8fa33119d6 feat: add support for VikingFile and display if link is available 2026-01-02 23:20:08 +02:00
68 changed files with 3180 additions and 814 deletions

View File

@@ -1,6 +1,7 @@
MAIN_VITE_API_URL=
MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
MAIN_VITE_NIMBUS_API_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

@@ -57,6 +57,7 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -73,6 +74,7 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -54,9 +54,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -71,9 +72,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_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 }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.6",
"version": "3.8.1",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -91,6 +91,7 @@
"user-agents": "^1.1.387",
"uuid": "^13.0.0",
"winreg": "^1.2.5",
"workwonders-sdk": "0.0.10",
"ws": "^8.18.1",
"yaml": "^2.6.1",
"yup": "^1.5.0"

View File

@@ -1,4 +1,5 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpDownloader:
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:
self.aria2.resume([self.download])
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]
def pause_download(self):
@@ -32,7 +37,11 @@ class HttpDownloader:
if self.download == None:
return None
download = self.aria2.get_download(self.download.gid)
try:
download = self.aria2.get_download(self.download.gid)
except DownloadNotFound:
self.download = None
return None
response = {
'folderName': download.name,

View File

@@ -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 []

View File

@@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil
from torrent_downloader import TorrentDownloader
from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor
from http_multi_link_downloader import HttpMultiLinkDownloader
import libtorrent as lt
app = Flask(__name__)
@@ -25,15 +24,7 @@ if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloading_game_id = initial_download['game_id']
if isinstance(initial_download['url'], list):
# 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'):
if initial_download['url'].startswith('magnet'):
torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader
try:
@@ -78,14 +69,6 @@ def status():
if not status:
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
@app.route("/seed-status", methods=["GET"])
@@ -104,21 +87,7 @@ def seed_status():
if not response:
continue
if isinstance(response, list):
# 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
if response.get('status') == 5: # Torrent seeding check
seed_status.append({
'gameId': game_id,
**response,
@@ -180,15 +149,7 @@ def action():
existing_downloader = downloads.get(game_id)
if isinstance(url, list):
# 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 url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path'])
else:

View File

@@ -175,6 +175,7 @@
"repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
"download_path": "Download path",
@@ -184,6 +185,12 @@
"open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings",
"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",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
@@ -397,6 +404,10 @@
"completed": "Completed",
"removed": "Not downloaded",
"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",
"remove": "Remove",
"downloading_metadata": "Downloading metadata…",
@@ -587,7 +598,10 @@
"notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game",
"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": {
"download_complete": "Download complete",
@@ -682,6 +696,7 @@
"blocked_users": "Blocked users",
"unblock": "Unblock",
"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",
@@ -709,8 +724,15 @@
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload 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",
"stats": "Stats",
"achievements": "achievements",
@@ -728,13 +750,10 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
"wrapped_2025": "Wrapped 2025"
},
"library": {
"library": "Library",
@@ -795,6 +814,7 @@
"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",

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar Sesión",
"friends": "Amigos",
"notifications": "Notificaciones",
"need_help": "¿Necesitás ayuda?",
"favorites": "Favoritos",
"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}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
"extracting": "Extrayendo {{title}}… ({{percentage}} completado)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Instalación completada",
"installation_complete_message": "Common redistributables instalados correctamente"
@@ -173,6 +175,7 @@
"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>",
"download_now": "Descargar ahora",
"loading": "Cargando...",
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga",
"download_path": "Ruta de descarga",
@@ -182,6 +185,12 @@
"open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes",
"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",
"no_executable_selected": "Sin ejecutable seleccionado",
"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",
"download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada",
"extracting": "Extrayendo",
"last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam",
@@ -394,6 +404,10 @@
"completed": "Completado",
"removed": "No descargado",
"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",
"remove": "Remover",
"downloading_metadata": "Descargando metadatos…",
@@ -414,7 +428,13 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"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": {
"downloads_path": "Ruta de descarga",
@@ -538,6 +558,7 @@
"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",
"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",
"top-left": "Superior Izquierda",
"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.",
"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",
"change_achievement_sound": "Cambiar sonido de logro",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
"download_source_failed": "Error",
"download_source_matched": "Actualizado",
"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"
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
"downloads": "Descargas",
"use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso"
},
"notifications": {
"download_complete": "Descarga completada",
@@ -651,6 +662,7 @@
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amistades",
"badges": "Insignias",
"friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario",
@@ -661,12 +673,17 @@
"ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud",
"undo_friendship": "Deshacer amistad",
"friendship_removed": "Amigo eliminado",
"request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear",
"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",
"no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie",
@@ -690,8 +707,16 @@
"report_reason_other": "Otros",
"profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"upload_banner": "Subir 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",
"stats": "Estadísticas",
"achievements": "logros",
@@ -710,11 +735,11 @@
"amount_minutes_short": "{{amount}}m",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña"
},
@@ -767,5 +792,41 @@
"all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente",
"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"
}
}

View File

@@ -673,8 +673,7 @@
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma",
"karma_count": "karmaa",
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
"karma_count": "karmaa"
},
"achievement": {
"achievement_unlocked": "Saavutus avattu",

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"notifications": "Értesítések",
"need_help": "Elakadtál?",
"favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
@@ -174,6 +175,7 @@
"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ásokban</0> változtathatod meg",
"download_now": "Letöltés",
"loading": "Töltés...",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók",
"download_path": "Letöltési hely",
@@ -182,7 +184,13 @@
"screenshot": "Screenshot {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ",
"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",
"no_executable_selected": "Nincs futtatható fájl tallózva",
"open_folder": "Mappa megnyitása",
@@ -418,9 +426,11 @@
"extract": "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. ",
"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"
"no": "Nem",
"network": "HÁLÓZAT",
"peak": "CSÚCS"
},
"settings": {
"downloads_path": "Letöltési útvonalak",
@@ -444,7 +454,7 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése",
"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",
"remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása",
@@ -556,6 +566,7 @@
"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",
"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",
"top-left": "Bal felső sarok",
"top-center": "Felső közép",
@@ -636,9 +647,9 @@
"sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva",
"playtime": "Játszottidő",
"total_play_time": "Teljes játszottidő",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
"playtime": "Játékidő",
"total_play_time": "Teljes játékidő",
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"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!",
"display_name": "Profilnév",
@@ -660,6 +671,7 @@
"sending": "Küldés..",
"friend_request_sent": "Barátfelkérés elküldve",
"friends": "Barátok",
"badges": "Kitűzők",
"friends_list": "Barát lista",
"user_not_found": "Felhasználó nem találva",
"block_user": "Felhasználó letiltása",
@@ -670,12 +682,16 @@
"ignore_request": "Kérés ignorálása",
"cancel_request": "Kérés visszavonása",
"undo_friendship": "Barát eltávolítása",
"friendship_removed": "Barát eltávolítva",
"request_accepted": "Barátfelkérés elfogadva",
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
"blocked_users": "Letiltott felhasználók",
"unblock": "Tiltás feloldása",
"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",
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
"no_blocked_users": "Nincs letiltott felhasználó",
@@ -699,6 +715,7 @@
"report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve",
"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",
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
@@ -718,10 +735,12 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"user_reviews": "Vélemények",
"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": "Könyvtár",
@@ -739,7 +758,7 @@
"amount_minutes": "{{amount}} perc",
"amount_hours_short": "{{amount}}ó",
"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",
"recently_played": "Nemrég Játszva",
"favorites": "Kedvencek"
@@ -772,5 +791,41 @@
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
"learn_more": "Tudj meg többet",
"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"
}
}

View File

@@ -673,8 +673,7 @@
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
"karma_count": "karma"
},
"achievement": {
"achievement_unlocked": "Sasniegums atbloķēts",

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login",
"friends": "Amigos",
"notifications": "Notificações",
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos",
"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",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download",
"loading": "Carregando...",
"no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download",
"download_path": "Diretório de download",
@@ -172,6 +174,12 @@
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"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",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
@@ -362,6 +370,7 @@
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"vote_failed": "Falha ao registrar seu voto. Por favor, tente novamente.",
"rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar",
@@ -384,6 +393,10 @@
"completed": "Concluído",
"removed": "Cancelado",
"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",
"remove": "Remover",
"downloading_metadata": "Baixando metadados…",
@@ -457,6 +470,7 @@
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas",
"removed_all_download_sources": "Todas as fontes de download removidas",
"cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo",
"title_confirmation_delete_all_sources": "Remover todas as fontes de download",
@@ -482,6 +496,7 @@
"blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"hydra_cloud": "Hydra Cloud",
"launch_minimized": "Iniciar o Hydra minimizado",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download",
@@ -544,6 +559,7 @@
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download",
"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",
"top-left": "Superior esquerdo",
"top-center": "Superior central",
@@ -561,6 +577,9 @@
"test_notification": "Testar notificação",
"achievement_sound_volume": "Volume do 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",
"preview": "Reproduzir",
"remove": "Remover",
@@ -568,7 +587,10 @@
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um 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": {
"download_complete": "Download concluído",
@@ -654,6 +676,7 @@
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos",
"badges": "Insígnias",
"add": "Adicionar",
"sending": "Enviando",
"friends_list": "Lista de amigos",
@@ -666,12 +689,17 @@
"ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade",
"friendship_removed": "Amigo removido",
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"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",
"no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
@@ -695,8 +723,16 @@
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"upload_banner": "Carregar 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",
"stats": "Estatísticas",
"achievements": "conquistas",
@@ -720,10 +756,10 @@
"achievements_earned": "Conquistas recebidas",
"karma": "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",
"user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação"
},
@@ -776,5 +812,41 @@
"all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente",
"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"
}
}

View File

@@ -508,7 +508,7 @@
"show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
"animated_profile_banner": "Banner animado no perfil",
"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"
}
}

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти",
"friends": "Друзья",
"notifications": "Уведомления",
"need_help": "Нужна помощь?",
"favorites": "Избранное",
"playable_button_title": "Показать только установленные игры.",
@@ -115,6 +116,7 @@
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)",
"extracting": "Распаковка {{title}}… ({{percentage}} завершено)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Установка завершена",
"installation_complete_message": "Библиотеки успешно установлены"
@@ -173,6 +175,7 @@
"repacks_modal_description": "Выберите репак для загрузки",
"select_folder_hint": "Чтобы изменить папку загрузок по умолчанию, откройте <0>Настройки</0>",
"download_now": "Загрузить сейчас",
"loading": "Загрузка...",
"no_shop_details": "Не удалось получить описание",
"download_options": "Источники",
"download_path": "Путь для загрузок",
@@ -182,6 +185,12 @@
"open_screenshot": "Открыть скриншот {{number}}",
"download_settings": "Параметры загрузки",
"downloader": "Загрузчик",
"downloader_online": "Онлайн",
"downloader_not_configured": "Доступен, но не настроен",
"downloader_offline": "Ссылка недоступна",
"downloader_not_available": "Недоступно",
"recommended": "Рекомендуется",
"go_to_settings": "Перейти в настройки",
"select_executable": "Выбрать",
"no_executable_selected": "Файл не выбран",
"open_folder": "Открыть папку",
@@ -202,6 +211,7 @@
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"extracting": "Распаковка",
"last_downloaded_option": "Последний вариант загрузки",
"new_download_option": "Новый",
"create_steam_shortcut": "Создать ярлык Steam",
@@ -394,6 +404,10 @@
"completed": "Завершено",
"removed": "Не скачано",
"cancel": "Отмена",
"cancel_download": "Отменить загрузку?",
"cancel_download_description": "Вы уверены, что хотите отменить эту загрузку? Все загруженные файлы будут удалены.",
"keep_downloading": "Нет, продолжить загрузку",
"yes_cancel": "Да, отменить",
"filter": "Поиск загруженных игр",
"remove": "Удалить",
"downloading_metadata": "Загрузка метаданных…",
@@ -414,7 +428,13 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"delete_archive_title": "Хотите удалить {{fileName}}?",
"delete_archive_description": "Файл был успешно распакован и больше не нужен.",
"yes": "Да",
"no": "Нет",
"network": "СЕТЬ",
"peak": "ПИК"
},
"settings": {
"downloads_path": "Путь загрузок",
@@ -550,6 +570,7 @@
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
"enable_steam_achievements": "Включить поиск достижений Steam",
"enable_new_download_options_badges": "Показывать значки новых вариантов загрузки",
"achievement_custom_notification_position": "Позиция уведомлений достижений",
"top-left": "Верхний левый угол",
"top-center": "Верхний центр",
@@ -567,6 +588,9 @@
"test_notification": "Тестовое уведомление",
"achievement_sound_volume": "Громкость звука достижения",
"select_achievement_sound": "Выбрать звук достижения",
"change_achievement_sound": "Изменить звук достижения",
"remove_achievement_sound": "Удалить звук достижения",
"preview_sound": "Предпросмотр звука",
"select": "Выбрать",
"preview": "Предпросмотр",
"remove": "Удалить",
@@ -574,7 +598,10 @@
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"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": {
"download_complete": "Загрузка завершена",
@@ -651,6 +678,7 @@
"sending": "Отправка",
"friend_request_sent": "Запрос в друзья отправлен",
"friends": "Друзья",
"badges": "Значки",
"friends_list": "Список друзей",
"user_not_found": "Пользователь не найден",
"block_user": "Заблокировать пользователя",
@@ -661,12 +689,17 @@
"ignore_request": "Игнорировать запрос",
"cancel_request": "Отменить запрос",
"undo_friendship": "Удалить друга",
"friendship_removed": "Друг удален",
"request_accepted": "Запрос принят",
"user_blocked_successfully": "Пользователь успешно заблокирован",
"user_block_modal_text": "{{displayName}} будет заблокирован",
"blocked_users": "Заблокированные пользователи",
"unblock": "Разблокировать",
"no_friends_added": "Вы ещё не добавили ни одного друга",
"no_friends_yet": "Вы ещё не добавили ни одного друга",
"view_all": "Показать все",
"load_more": "Загрузить еще",
"loading": "Загрузка",
"pending": "Ожидание",
"no_pending_invites": "У вас нет запросов ожидающих ответа",
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
@@ -690,8 +723,16 @@
"report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга",
"copied": "Скопировано!",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
"change_banner": "Изменить баннер",
"replace_banner": "Заменить баннер",
"remove_banner": "Удалить баннер",
"remove_banner_modal_title": "Удалить баннер?",
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
"remove": "Удалить",
"background_image_updated": "Фоновое изображение обновлено",
"stats": "Статистика",
"achievements": "Достижения",
@@ -709,9 +750,9 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
@@ -764,5 +805,41 @@
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
},
"notifications_page": {
"title": "Уведомления",
"mark_all_as_read": "Отметить все как прочитанные",
"clear_all": "Очистить все",
"loading": "Загрузка...",
"empty_title": "Нет уведомлений",
"empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
"empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
"filter_all": "Все",
"filter_unread": "Непрочитанные",
"filter_friends": "Друзья",
"filter_badges": "Значки",
"filter_upvotes": "Голоса",
"filter_local": "Локальные",
"load_more": "Загрузить еще",
"dismiss": "Отклонить",
"accept": "Принять",
"refuse": "Отклонить",
"notification": "Уведомление",
"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": "Запрос в друзья отклонен"
}
}

View File

@@ -706,7 +706,6 @@
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
"user_reviews": "İncelemeler",
"delete_review": "İncelemeyi Sil",
"loading_reviews": "İncelemeler yükleniyor..."

View File

@@ -668,8 +668,7 @@
"game_removed_from_pinned": "Гру видалено із закріплених",
"game_added_to_pinned": "Гру додано до закріплених",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Зароблена позитивними оцінками на відгуках"
"karma_count": "карма"
},
"achievement": {
"achievement_unlocked": "Досягнення розблоковано",

View File

@@ -689,7 +689,6 @@
"game_removed_from_pinned": "游戏已从置顶移除",
"karma": "业力",
"karma_count": "业力值",
"karma_description": "通过评论获得的点赞",
"loading_reviews": "正在加载评价...",
"manual_playtime_tooltip": "该游戏时长已手动更新",
"pinned": "已置顶",

View 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);

View File

@@ -13,6 +13,7 @@ import "./delete-game-folder";
import "./extract-game-download";
import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id";
import "./get-game-installer-action-type";
import "./get-library";
import "./open-game-executable-path";
import "./open-game-installer-path";

View File

@@ -51,22 +51,30 @@ const updateProfile = async (
"backgroundImageUrl",
]);
if (updateProfile.profileImageUrl) {
const profileImageUrl = await uploadImage(
"profile-image",
updateProfile.profileImageUrl
).catch(() => undefined);
if (updateProfile.profileImageUrl !== undefined) {
if (updateProfile.profileImageUrl === null) {
payload["profileImageUrl"] = null;
} else {
const profileImageUrl = await uploadImage(
"profile-image",
updateProfile.profileImageUrl
).catch(() => undefined);
payload["profileImageUrl"] = profileImageUrl;
payload["profileImageUrl"] = profileImageUrl;
}
}
if (updateProfile.backgroundImageUrl) {
const backgroundImageUrl = await uploadImage(
"background-image",
updateProfile.backgroundImageUrl
).catch(() => undefined);
if (updateProfile.backgroundImageUrl !== undefined) {
if (updateProfile.backgroundImageUrl === null) {
payload["backgroundImageUrl"] = null;
} else {
const backgroundImageUrl = await uploadImage(
"background-image",
updateProfile.backgroundImageUrl
).catch(() => undefined);
payload["backgroundImageUrl"] = backgroundImageUrl;
payload["backgroundImageUrl"] = backgroundImageUrl;
}
}
return patchUserProfile(payload);

View File

@@ -2,7 +2,7 @@ import { downloadsSublevel } from "./level/sublevels/downloads";
import { orderBy } from "lodash-es";
import { Downloader } from "@shared";
import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types";
import type { Download, UserPreferences } from "@types";
import {
SystemPath,
CommonRedistManager,
@@ -18,6 +18,7 @@ import {
DeckyPlugin,
DownloadSourcesChecker,
WSClient,
logger,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -71,18 +72,47 @@ export const loadState = async () => {
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) {
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), {
await downloadsSublevel.put(downloadKey, {
...download,
extracting: false,
});
}
});
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
// 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 downloadsToSeed = downloads.filter(
// Re-fetch downloads after status updates
const updatedDownloads = await downloadsSublevel
.values()
.all()
.then((games) => orderBy(games, "timestamp", "desc"));
// Prioritize interrupted download, then queued downloads
const downloadToResume =
interruptedDownload ?? updatedDownloads.find((game) => game.queued);
const downloadsToSeed = updatedDownloads.filter(
(game) =>
game.shouldSeed &&
game.downloader === Downloader.Torrent &&
@@ -90,7 +120,23 @@ export const loadState = async () => {
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();

View File

@@ -46,7 +46,7 @@ export class SevenZip {
onProgress?: (progress: ExtractionProgress) => void
): Promise<ExtractionResult> {
return new Promise((resolve, reject) => {
const tryPassword = (index = -1) => {
const tryPassword = (index = 0) => {
const password = passwords[index] ?? "";
logger.info(
`Trying password "${password || "(empty)"}" on ${filePath}`
@@ -115,7 +115,7 @@ export class SevenZip {
});
};
tryPassword();
tryPassword(0);
});
}

View File

@@ -4,10 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types";
import {
GofileApi,
QiwiApi,
DatanodesApi,
MediafireApi,
PixelDrainApi,
VikingFileApi,
RootzApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -17,7 +18,7 @@ import {
} from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid";
import path from "path";
import path from "node:path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
@@ -25,9 +26,13 @@ import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
import { JsHttpDownloader } from "./js-http-downloader";
export class DownloadManager {
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,
@@ -51,7 +56,7 @@ export class DownloadManager {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1];
const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
@@ -67,6 +72,34 @@ export class DownloadManager {
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,
@@ -98,6 +131,19 @@ export class DownloadManager {
};
}
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(
download?: Download,
downloadsToSeed?: Download[]
@@ -122,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>(
"/status"
);
@@ -171,105 +297,141 @@ export class DownloadManager {
gameId: downloadId,
download,
} as DownloadProgress;
} catch (err) {
} catch {
return null;
}
}
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
if (this.usingJsDownloader) {
return this.getDownloadStatusFromJs();
}
return this.getDownloadStatusFromRpc();
}
public static async watchDownloads() {
const status = await this.getDownloadStatus();
if (!status) return;
if (status) {
const { gameId, progress } = status;
const { gameId, progress } = status;
const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
if (!download || !game) return;
if (!download || !game) return;
this.sendProgressUpdate(progress, status, game);
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
if (progress === 1) {
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 })
);
}
}
private static async handleDownloadCompletion(
download: Download,
game: any,
gameId: string
) {
publishDownloadCompleteNotification(game);
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
await this.updateDownloadStatus(
download,
gameId,
userPreferences?.seedAfterDownloadComplete
);
if (download.automaticallyExtract) {
this.handleExtraction(download, game);
}
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, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
});
} else {
await downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
extracting: shouldExtract,
});
this.cancelDownload(gameId);
}
}
private static handleExtraction(download: Download, game: any) {
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
)
.then(() => gameFilesManager.setExtractionComplete());
}
}
private static async processNextQueuedDownload() {
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
);
}
const [nextItemOnQueue] = downloads;
const shouldExtract = download.automaticallyExtract;
if (progress === 1 && download) {
publishDownloadCompleteNotification(game);
if (
userPreferences?.seedAfterDownloadComplete &&
download.downloader === Downloader.Torrent
) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
});
} else {
await downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
extracting: shouldExtract,
});
this.cancelDownload(gameId);
}
if (shouldExtract) {
const gameFilesManager = new GameFilesManager(
game.shop,
game.objectId
);
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
)
.then(() => gameFilesManager.setExtractionComplete());
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
const [nextItemOnQueue] = downloads;
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = null;
}
}
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = null;
this.usingJsDownloader = false;
this.jsDownloader = null;
}
}
@@ -309,12 +471,17 @@ export class DownloadManager {
}
static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Pausing JS download");
this.jsDownloader.pauseDownload();
} else {
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
}
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
@@ -327,14 +494,23 @@ export class DownloadManager {
}
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
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
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
}
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingJsDownloader = false;
}
}
@@ -354,6 +530,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) {
const downloadId = levelKeys.game(download.shop, download.objectId);
@@ -385,15 +796,6 @@ export class DownloadManager {
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: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return {
@@ -499,12 +901,75 @@ export class DownloadManager {
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) {
const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
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);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = downloadId;
this.usingJsDownloader = false;
}
}
}

View File

@@ -1,3 +1,4 @@
export * from "./download-manager";
export * from "./real-debrid";
export * from "./torbox";
export * from "./js-http-downloader";

View 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;
}
}

View File

@@ -1,4 +1,6 @@
import axios from "axios";
import http from "node:http";
import https from "node:https";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
@@ -28,6 +30,12 @@ export class BuzzheavierApi {
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`;
@@ -43,6 +51,12 @@ export class BuzzheavierApi {
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"];

View File

@@ -1,7 +1,8 @@
export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes";
export * from "./mediafire";
export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";
export * from "./vikingfile";
export * from "./rootz";

View File

@@ -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;
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import http from "node:http";
import cp from "node:child_process";
import fs from "node:fs";
@@ -31,6 +32,9 @@ export class PythonRPC {
public static readonly RPC_PORT = "8084";
public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
});
private static pythonProcess: cp.ChildProcess | null = null;

View File

@@ -7,6 +7,7 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
readonly MAIN_VITE_NIMBUS_API_URL: string;
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string;
}

View File

@@ -206,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId),
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
openGameExecutablePath: (shop: GameShop, objectId: string) =>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -52,6 +52,8 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWondersSdk | null>(null);
const {
hasActiveSubscription,
fetchUserDetails,
@@ -114,7 +116,30 @@ export function App() {
return () => unsubscribe();
}, [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 WorkWondersSdk();
await workwondersRef.current.init({
organization: "hydra",
token,
locale: parsedLocale,
});
await workwondersRef.current.initChangelogWidget();
workwondersRef.current.initChangelogWidgetMini();
workwondersRef.current.initFeedbackWidget();
},
[workwondersRef]
);
const setupExternalResources = useCallback(async () => {
const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) {
@@ -125,21 +150,26 @@ export function App() {
dispatch(setProfileBackground(profileBackground));
}
fetchUserDetails()
.then((response) => {
if (response) {
updateUserDetails(response);
}
})
.finally(() => {
if (document.getElementById("external-resources")) return;
const userPreferences = await window.electron.getUserPreferences();
const userDetails = await fetchUserDetails().catch(() => null);
const $script = document.createElement("script");
$script.id = "external-resources";
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script);
});
}, [fetchUserDetails, updateUserDetails, dispatch]);
if (userDetails) {
updateUserDetails(userDetails);
}
setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
if (!document.getElementById("external-resources")) {
const $script = document.createElement("script");
$script.id = "external-resources";
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script);
}
}, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
useEffect(() => {
setupExternalResources();
}, [setupExternalResources]);
const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
@@ -203,6 +233,7 @@ export function App() {
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
workwondersRef.current?.notifyUrlChange();
}, [location.pathname, location.search]);
useEffect(() => {

View File

@@ -8,6 +8,7 @@
min-width: 200px;
flex-direction: column;
align-items: center;
animation: dropdown-menu-fade-in 0.2s ease-out;
}
&__group {
@@ -66,3 +67,14 @@
justify-content: center;
}
}
@keyframes dropdown-menu-fade-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -224,21 +224,6 @@ export function Header() {
setActiveIndex(-1);
};
useEffect(() => {
const prevPath = sessionStorage.getItem("prevPath");
const currentPath = location.pathname;
if (
prevPath?.startsWith("/catalogue") &&
!currentPath.startsWith("/catalogue") &&
catalogueSearchValue
) {
dispatch(setFilters({ title: "" }));
}
sessionStorage.setItem("prevPath", currentPath);
}, [location.pathname, catalogueSearchValue, dispatch]);
useEffect(() => {
if (!isDropdownVisible) return;

View File

@@ -1,19 +1,20 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Supernova";
export const VERSION_CODENAME = "Harbinger";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
[Downloader.Torrent]: "Torrent",
[Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile",
[Downloader.Rootz]: "Rootz",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

@@ -167,6 +167,10 @@ declare global {
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
getGameInstallerActionType: (
shop: GameShop,
objectId: string
) => Promise<"install" | "open-folder">;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
openGame: (

View File

@@ -31,11 +31,16 @@ export const downloadSlice = createSlice({
reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
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 (action.payload?.gameId && action.payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload;
if (payload?.gameId && payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = payload;
// Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0;

View File

@@ -59,6 +59,7 @@ export function useUserDetails() {
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
workwondersJwt: userDetails?.workwondersJwt || "",
karma: userDetails?.karma || 0,
});
},
@@ -111,7 +112,7 @@ export function useUserDetails() {
);
const undoFriendship = (userId: string) =>
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
const blockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/block`);

View File

@@ -427,7 +427,7 @@
padding: 0;
cursor: pointer;
text-align: left;
width: 100%;
width: fit-content;
transition: opacity 0.2s ease;
&:focus,
@@ -509,6 +509,15 @@
&__simple-menu-btn {
padding: calc(globals.$spacing-unit);
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 {

View File

@@ -1,6 +1,6 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import { Badge, Button, ConfirmationModal } from "@renderer/components";
import {
formatDownloadProgress,
buildGameDetailsPath,
@@ -32,12 +32,12 @@ import {
FileDirectoryIcon,
LinkIcon,
PlayIcon,
ThreeBarsIcon,
TrashIcon,
UnlinkIcon,
XCircleIcon,
GraphIcon,
} from "@primer/octicons-react";
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js";
interface AnimatedPercentageProps {
@@ -219,7 +219,7 @@ interface HeroDownloadViewProps {
calculateETA: () => string;
pauseDownload: (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;
}
@@ -238,7 +238,7 @@ function HeroDownloadView({
calculateETA,
pauseDownload,
resumeDownload,
cancelDownload,
onCancelClick,
t,
}: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate();
@@ -353,7 +353,7 @@ function HeroDownloadView({
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
onClick={() => onCancelClick(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
@@ -452,6 +452,7 @@ export function DownloadGroup({
seedingStatus,
}: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
const navigate = useNavigate();
const userPreferences = useAppSelector(
@@ -523,6 +524,16 @@ export function DownloadGroup({
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
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(
async (imageUrl: string, gameId: string) => {
@@ -613,11 +624,18 @@ export function DownloadGroup({
const download = game.download!;
const isGameDownloading = isGameDownloadingMap[game.id];
if (download.fileSize != null) return formatBytes(download.fileSize);
if (lastPacket?.download.fileSize && isGameDownloading)
// Check lastPacket first for most up-to-date size during active downloads
if (
isGameDownloading &&
lastPacket?.download.fileSize &&
lastPacket.download.fileSize > 0
)
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";
};
@@ -651,6 +669,27 @@ export function DownloadGroup({
[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 download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id];
@@ -659,14 +698,6 @@ export function DownloadGroup({
if (game.download?.progress === 1) {
const actions = [
{
label: t("install"),
disabled: deleting,
onClick: () => {
openGameInstaller(game.shop, game.objectId);
},
icon: <DownloadIcon />,
},
{
label: t("extract"),
disabled: game.download.extracting,
@@ -721,7 +752,7 @@ export function DownloadGroup({
{
label: t("cancel"),
onClick: () => {
cancelDownload(game.shop, game.objectId);
handleCancelClick(game.shop, game.objectId);
},
icon: <XCircleIcon />,
},
@@ -746,7 +777,7 @@ export function DownloadGroup({
{
label: t("cancel"),
onClick: () => {
cancelDownload(game.shop, game.objectId);
handleCancelClick(game.shop, game.objectId);
},
icon: <XCircleIcon />,
},
@@ -770,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;
const isDownloadingGroup = title === t("download_in_progress");
@@ -804,136 +866,179 @@ export function DownloadGroup({
const dominantColor = dominantColors[game.id] || "#fff";
return (
<HeroDownloadView
game={game}
isGameDownloading={isGameDownloading}
isGameExtracting={isGameExtracting}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
cancelDownload={cancelDownload}
t={t}
/>
<>
<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
game={game}
isGameDownloading={isGameDownloading}
isGameExtracting={isGameExtracting}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
onCancelClick={handleCancelClick}
t={t}
/>
</>
);
}
return (
<div
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
>
<div className="download-group__header">
<div className="download-group__header-title-group">
<h2>{title}</h2>
<h3 className="download-group__header-count">{library.length}</h3>
<>
<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
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
>
<div className="download-group__header">
<div className="download-group__header-title-group">
<h2>{title}</h2>
<h3 className="download-group__header-count">{library.length}</h3>
</div>
</div>
</div>
<ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return (
<li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
<div className="download-group__simple-info">
<ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return (
<li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button"
className="download-group__simple-thumbnail"
>
<h3 className="download-group__simple-title">{game.title}</h3>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
</Badge>
</div>
<div className="download-group__simple-meta-row">
{extraction?.visibleId === game.id ? (
<span className="download-group__simple-extracting">
{t("extracting")} (
{Math.round(extraction.progress * 100)}%)
</span>
) : (
<span className="download-group__simple-size">
<DownloadIcon size={14} />
{size}
</span>
)}
{game.download?.progress === 1 && seeding && (
<span className="download-group__simple-seeding">
{t("seeding")}
</span>
)}
<div className="download-group__simple-info">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button"
>
<h3 className="download-group__simple-title">
{game.title}
</h3>
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
</Badge>
</div>
<div className="download-group__simple-meta-row">
{extraction?.visibleId === game.id ? (
<span className="download-group__simple-extracting">
{t("extracting")} (
{Math.round(extraction.progress * 100)}%)
</span>
) : (
<span className="download-group__simple-size">
<DownloadIcon size={14} />
{size}
</span>
)}
{game.download?.progress === 1 && seeding && (
<span className="download-group__simple-seeding">
{t("seeding")}
</span>
)}
</div>
</div>
</div>
</div>
{isQueuedGroup && (
<div className="download-group__simple-progress">
<span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)}
</span>
<div className="download-group__progress-bar download-group__progress-bar--small">
<div
className="download-group__progress-fill"
style={{
width: `${progress * 100}%`,
backgroundColor: "#fff",
}}
/>
{isQueuedGroup && (
<div className="download-group__simple-progress">
<span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)}
</span>
<div className="download-group__progress-bar download-group__progress-bar--small">
<div
className="download-group__progress-fill"
style={{
width: `${progress * 100}%`,
backgroundColor: "#fff",
}}
/>
</div>
</div>
</div>
)}
)}
<div className="download-group__simple-actions">
{game.download?.progress === 1 && (
<Button
theme="primary"
onClick={() => openGameInstaller(game.shop, game.objectId)}
disabled={isGameDeleting(game.id)}
className="download-group__simple-menu-btn"
>
<PlayIcon size={16} />
</Button>
)}
{isQueuedGroup && game.download?.progress !== 1 && (
<Button
theme="primary"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__simple-menu-btn"
tooltip={t("resume")}
>
<DownloadIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
theme="outline"
className="download-group__simple-menu-btn"
>
<ThreeBarsIcon />
</Button>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</div>
<div className="download-group__simple-actions">
{game.download?.progress === 1 &&
(() => {
const actionType =
gameActionTypes[game.id] || "open-folder";
const isInstall = actionType === "install";
return (
<Button
theme="primary"
onClick={() =>
openGameInstaller(game.shop, game.objectId)
}
disabled={isGameDeleting(game.id)}
className="download-group__simple-action-btn"
>
{isInstall ? (
<>
<DownloadIcon size={16} />
{t("install")}
</>
) : (
<>
<Folder size={16} />
{tGameDetails("open_folder")}
</>
)}
</Button>
);
})()}
{isQueuedGroup && game.download?.progress !== 1 && (
<Button
theme="primary"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__simple-menu-btn"
tooltip={t("resume")}
>
<DownloadIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
theme="outline"
className="download-group__simple-menu-btn"
>
<MoreVertical size={16} />
</Button>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</div>
</>
);
}

View File

@@ -19,23 +19,173 @@
color: globals.$body-color;
}
&__downloaders {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
&__downloaders-list-wrapper {
border: 1px solid globals.$border-color;
overflow: hidden;
background-color: globals.$dark-background-color;
}
&__downloader-option {
position: relative;
&__downloaders-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&:only-child {
grid-column: 1 / -1;
&::-webkit-scrollbar {
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 {
position: absolute;
left: calc(globals.$spacing-unit * 2);
&__downloader-item {
display: flex;
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 {
@@ -49,4 +199,17 @@
&__change-path-button {
align-self: flex-end;
}
&__loading-spinner {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,17 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Badge,
Button,
CheckboxField,
Link,
Modal,
TextField,
} from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants";
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";
export interface DownloadSettingsModalProps {
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null
);
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature();
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
}
}, [visible, checkFolderWritePermission, selectedPath]);
const downloaders = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.uris]);
const downloadOptions = useMemo(() => {
const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
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(
(availableDownloaders: Downloader[]) => {
if (availableDownloaders.length === 0) return null;
if (availableDownloaders.includes(Downloader.Hydra)) {
return Downloader.Hydra;
}
if (availableDownloaders.includes(Downloader.RealDebrid)) {
return Downloader.RealDebrid;
}
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
}
const filteredDownloaders = downloaders.filter((downloader) => {
if (downloader === Downloader.RealDebrid)
return userPreferences?.realDebridApiToken;
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
});
const availableDownloaders = downloadOptions
.filter((option) => option.isAvailable)
.map((option) => option.downloader);
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
}, [
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
@@ -186,33 +252,144 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => {
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&
!isFeatureEnabled(Feature.Nimbus));
<div className="download-settings-modal__downloaders-list-wrapper">
<div className="download-settings-modal__downloaders-list">
{downloadOptions.map((option, index) => {
const isSelected = selectedDownloader === option.downloader;
const tooltipId = `availability-indicator-${option.downloader}`;
const isLastItem = index === downloadOptions.length - 1;
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
const Indicator = option.isAvailable ? motion.span : "span";
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")}
/>
);
}
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
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 (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_available")}
/>
);
};
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,
}}
>
<CheckCircleFillIcon
size={16}
className="download-settings-modal__check-icon"
/>
</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>
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
disabled={
downloadStarting ||
selectedDownloader === null ||
!hasWritePermission
!hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
}
>
<DownloadIcon />
{t("download_now")}
{downloadStarting ? (
<>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
</Button>
</div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal>
);
}

View File

@@ -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;
}
}
}

View File

@@ -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>
);
}

View File

@@ -8,6 +8,72 @@
width: 100%;
max-width: 800px;
margin: 0 auto;
min-height: calc(100vh - 200px);
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
&__filter-tabs {
display: flex;
gap: globals.$spacing-unit;
position: relative;
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active {
color: white;
}
}
&__tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 20px;
}
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
&__actions {
display: flex;
@@ -15,22 +81,37 @@
justify-content: flex-end;
}
&__content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding-bottom: calc(globals.$spacing-unit * 3);
}
&__empty {
display: flex;
flex: 1;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__empty-filter {
display: flex;
justify-content: center;
align-items: center;
padding: calc(globals.$spacing-unit * 6);
color: globals.$body-color;
}
&__icon-container {
width: 60px;
height: 60px;
@@ -54,5 +135,6 @@
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit * 3);
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
@@ -18,6 +18,11 @@ import type {
} from "@types";
import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast();
@@ -34,12 +39,14 @@ export default function Notifications() {
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set());
const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
skip: 0,
});
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => {
try {
@@ -65,7 +72,11 @@ export default function Notifications() {
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => {
async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return;
try {
@@ -74,7 +85,7 @@ export default function Notifications() {
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: "all", take: 20, skip },
params: { filter: filterParam, take: 20, skip },
needsAuth: true,
}
);
@@ -101,24 +112,24 @@ export default function Notifications() {
[userDetails]
);
const fetchAllNotifications = useCallback(async () => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(),
]);
setIsLoading(false);
}, [
fetchLocalNotifications,
fetchBadges,
fetchApiNotifications,
userDetails,
]);
const fetchAllNotifications = useCallback(
async (filterParam: NotificationFilter = "all") => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails
? fetchApiNotifications(0, false, filterParam)
: Promise.resolve(),
]);
setIsLoading(false);
},
[fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
);
useEffect(() => {
fetchAllNotifications();
}, [fetchAllNotifications]);
fetchAllNotifications(filter);
}, [fetchAllNotifications, filter]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
@@ -130,6 +141,13 @@ export default function Notifications() {
return () => unsubscribe();
}, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -144,23 +162,28 @@ export default function Notifications() {
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map(
(n) => ({
// Filter local notifications based on current filter
const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
...n,
source: "local" as const,
})
);
}));
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate
);
return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications]);
}, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id));
}, [mergedNotifications, clearingIds]);
return mergedNotifications;
}, [mergedNotifications]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
@@ -251,42 +274,86 @@ export default function Notifications() {
[showErrorToast, t, notifyCountChange]
);
const removeNotificationFromState = useCallback(
(notification: MergedNotification) => {
if (notification.source === "api") {
setApiNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
} else {
setLocalNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}
},
[]
);
const removeNotificationWithDelay = useCallback(
(notification: MergedNotification, delayMs: number): Promise<void> => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
removeNotificationFromState(notification);
resolve();
}, delayMs);
clearingTimeoutsRef.current.push(timeout);
});
},
[removeNotificationFromState]
);
const handleClearAll = useCallback(async () => {
if (isClearing) return;
try {
// Mark all as clearing for animation
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
setIsClearing(true);
// Wait for exit animation
await new Promise((resolve) => setTimeout(resolve, 300));
// Clear any existing timeouts
clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Clear all API notifications
if (userDetails && apiNotifications.length > 0) {
// Snapshot current notifications for staggered removal
const notificationsToRemove = [...displayedNotifications];
const totalNotifications = notificationsToRemove.length;
if (totalNotifications === 0) {
setIsClearing(false);
return;
}
// Remove items one by one with staggered delays for visual effect
const removalPromises = notificationsToRemove.map((notification, index) =>
removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS)
);
// Wait for all items to be removed from state
await Promise.all(removalPromises);
// Wait for the last exit animation to complete
await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS));
// Perform actual backend deletions (state is already cleared by staggered removal)
if (userDetails) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true,
});
setApiNotifications([]);
}
// Clear all local notifications
await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
}
}, [
apiNotifications,
localNotifications,
displayedNotifications,
isClearing,
removeNotificationWithDelay,
userDetails,
showSuccessToast,
showErrorToast,
@@ -296,9 +363,19 @@ export default function Notifications() {
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true);
fetchApiNotifications(pagination.skip, true, filter);
}
}, [pagination, isLoading, fetchApiNotifications]);
}, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted"));
@@ -317,10 +394,13 @@ export default function Notifications() {
return (
<motion.div
key={key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }}
exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
@@ -343,8 +423,57 @@ export default function Notifications() {
);
};
const unreadCount = useMemo(() => {
const apiUnread = apiNotifications.filter((n) => !n.isRead).length;
const localUnread = localNotifications.filter((n) => !n.isRead).length;
return apiUnread + localUnread;
}, [apiNotifications, localNotifications]);
const renderFilterTabs = () => (
<div className="notifications__filter-tabs">
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "all" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("all")}
>
{t("filter_all")}
</button>
{filter === "all" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "unread" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("unread")}
>
{t("filter_unread")}
{unreadCount > 0 && (
<span className="notifications__tab-badge">{unreadCount}</span>
)}
</button>
{filter === "unread" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
</div>
);
const hasNoNotifications = mergedNotifications.length === 0;
const shouldDisableActions = isClearing || hasNoNotifications;
const renderContent = () => {
if (isLoading && mergedNotifications.length === 0) {
if (isLoading && hasNoNotifications) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
@@ -352,36 +481,61 @@ export default function Notifications() {
);
}
if (mergedNotifications.length === 0) {
return (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p>
</div>
);
}
return (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
<div className="notifications__header">
{renderFilterTabs()}
<div className="notifications__actions">
<Button
theme="outline"
onClick={handleMarkAllAsRead}
disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
</div>
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
{/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="wait">
<motion.div
key={filter}
className="notifications__content-wrapper"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{hasNoNotifications && !isClearing ? (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>
{filter === "unread"
? t("empty_filter_description")
: t("empty_description")}
</p>
</div>
) : (
<div className="notifications__list">
<AnimatePresence>
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
)}
</motion.div>
</AnimatePresence>
{pagination.hasMore && (
{pagination.hasMore && !isClearing && (
<div className="notifications__load-more">
<Button
theme="outline"

View File

@@ -100,8 +100,10 @@
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: none;
cursor: pointer;
transition: all ease 0.2s;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.1);

View File

@@ -4,6 +4,20 @@
&__box {
padding: calc(globals.$spacing-unit * 2);
position: relative;
&--empty {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
}
&__empty-text {
color: globals.$muted-color;
font-size: globals.$small-font-size;
margin: 0;
text-align: center;
}
&__add-friend-button {

View File

@@ -19,6 +19,7 @@ export function FriendsBox() {
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
const hasFriends = userProfile?.friends && userProfile.friends.length > 0;
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
@@ -35,7 +36,15 @@ export function FriendsBox() {
return <SteamLogo width={16} height={16} />;
};
if (!userProfile?.friends.length) return null;
if (!hasFriends) {
if (!isMe) return null;
return (
<div className="friends-box__box friends-box__box--empty">
<p className="friends-box__empty-text">{t("no_friends_yet")}</p>
</div>
);
}
const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS);
const totalFriends = userProfile.friends.length;

View File

@@ -376,7 +376,7 @@ export function ProfileContent() {
const hasAnyGames = hasGames || hasPinnedGames;
const shouldShowRightContent =
hasAnyGames || userProfile.friends.length > 0;
hasAnyGames || userProfile.friends.length > 0 || isMe;
return (
<section className="profile-content__section">
@@ -444,7 +444,7 @@ export function ProfileContent() {
<RecentGamesBox />
</ProfileSection>
)}
{userProfile?.friends.length > 0 && (
{(userProfile?.friends.length > 0 || isMe) && (
<ProfileSection
title={t("friends")}
count={userStats?.friendsCount || userProfile.friends.length}

View File

@@ -29,6 +29,12 @@
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
&--wrapped {
&:hover {
background-color: transparent;
}
}
}
&__list-title {
@@ -70,4 +76,15 @@
font-size: 0.75rem;
line-height: 1.4;
}
&__wrapped-link {
background: none;
border: none;
padding: 0;
text-align: start;
color: globals.$body-color;
font-size: 0.875rem;
cursor: pointer;
text-decoration: underline;
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext } from "react";
import { useCallback, useContext, useState } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks";
@@ -7,9 +7,11 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { Award } from "lucide-react";
import { WrappedFullscreenModal } from "./wrapped-tab";
import "./user-stats-box.scss";
export function UserStatsBox() {
const [showWrappedModal, setShowWrappedModal] = useState(false);
const { showHydraCloudModal } = useSubscription();
const { userStats, isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
@@ -41,6 +43,18 @@ export function UserStatsBox() {
return (
<div className="user-stats__box">
<ul className="user-stats__list">
{userProfile?.hasCompletedWrapped2025 && (
<li className="user-stats__list-item user-stats__list-item--wrapped">
<button
type="button"
onClick={() => setShowWrappedModal(true)}
className="user-stats__wrapped-link"
>
Wrapped 2025
</button>
</li>
)}
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">
@@ -123,14 +137,17 @@ export function UserStatsBox() {
{t("karma_count")}
</p>
</div>
<div className="user-stats__karma-info">
<small className="user-stats__karma-info-text">
{t("karma_description")}
</small>
</div>
</li>
)}
</ul>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
</div>
);
}

View File

@@ -144,11 +144,6 @@
}
}
&__left-actions {
display: flex;
gap: globals.$spacing-unit;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
@@ -160,35 +155,5 @@
&--outline {
border-color: globals.$body-color;
}
&--wrapped {
background: linear-gradient(
120deg,
#2a57ff 0%,
#2951e6 11%,
#2f5bff 16%,
#2c56e8 29%,
#244acc 34%,
#2245c2 40%,
#3a6bff 45%,
#3766f2 50%,
#2444b8 56%,
#122a73 82%,
#2348b3 86%,
#1f429e 87%,
#10286a 93%,
#0e2a63 100%
);
background-color: #2a57ff;
background-size: 105% 100%;
background-position: 100% 50%;
border: none;
color: white;
transition: background-position 0.4s ease;
&:hover {
background-position: 0% 50%;
}
}
}
}

View File

@@ -7,7 +7,6 @@ import {
PencilIcon,
PersonAddIcon,
SignOutIcon,
TrophyIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
@@ -30,7 +29,6 @@ import { motion } from "framer-motion";
import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import "./profile-hero.scss";
@@ -41,10 +39,10 @@ type FriendAction =
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showWrappedModal, setShowWrappedModal] = useState(false);
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext);
@@ -261,9 +259,23 @@ export function ProfileHero() {
const copyFriendCode = useCallback(() => {
if (userProfile?.id) {
navigator.clipboard.writeText(userProfile.id);
showSuccessToast(t("friend_code_copied"));
setIsCopied(true);
const startTime = performance.now();
const duration = 1200; // 1.2 seconds
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
if (elapsed < duration) {
requestAnimationFrame(animate);
} else {
setIsCopied(false);
}
};
requestAnimationFrame(animate);
}
}, [userProfile, showSuccessToast, t]);
}, [userProfile]);
const currentGame = useMemo(() => {
if (isMe) {
@@ -286,13 +298,6 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)}
/>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
<FullscreenMediaModal
visible={showFullscreenAvatar}
onClose={() => setShowFullscreenAvatar(false)}
@@ -348,7 +353,7 @@ export function ProfileHero() {
onMouseLeave={() => setIsCopyButtonHovered(false)}
initial={{ width: 28 }}
animate={{
width: isCopyButtonHovered ? 105 : 28,
width: isCopyButtonHovered || isCopied ? 105 : 28,
}}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
@@ -356,12 +361,12 @@ export function ProfileHero() {
className="profile-hero__friend-code"
initial={{ opacity: 0, marginRight: 0 }}
animate={{
opacity: isCopyButtonHovered ? 1 : 0,
marginRight: isCopyButtonHovered ? 8 : 0,
opacity: isCopyButtonHovered || isCopied ? 1 : 0,
marginRight: isCopyButtonHovered || isCopied ? 8 : 0,
}}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{userProfile?.id}
{isCopied ? t("copied") : userProfile?.id}
</motion.span>
<CopyIcon size={16} />
</motion.button>
@@ -410,22 +415,6 @@ export function ProfileHero() {
background: !backgroundImage ? heroBackground : undefined,
}}
>
{userProfile?.hasCompletedWrapped2025 && (
<div className="profile-hero__left-actions">
<Button
theme="outline"
onClick={() => setShowWrappedModal(true)}
className="profile-hero__button--wrapped"
>
<TrophyIcon />
{isMe
? t("view_my_wrapped_button")
: t("view_wrapped_button", {
displayName: userProfile.displayName,
})}
</Button>
</div>
)}
<div className="profile-hero__actions">{profileActions}</div>
</div>
</section>

View File

@@ -1,11 +1,86 @@
@use "../../../scss/globals.scss";
.upload-background-image-button {
position: absolute;
top: 16px;
right: 16px;
&__wrapper {
position: absolute;
top: 16px;
right: 16px;
}
border-color: globals.$body-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
&__menu {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
padding: 4px;
display: flex;
flex-direction: column;
animation: menu-fade-in 0.2s ease-out;
&--closing {
animation: menu-fade-out 0.15s ease-in;
}
}
&__menu-item {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
border-radius: 4px;
padding: 5px 12px;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
font-size: 14px;
background: none;
border: none;
color: globals.$body-color;
text-align: left;
&:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
&:disabled {
cursor: default;
opacity: 0.6;
}
&:focus {
background-color: rgba(255, 255, 255, 0.1);
outline: none;
}
}
}
@keyframes menu-fade-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes menu-fade-out {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
}

View File

@@ -1,6 +1,8 @@
import { UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useContext, useState } from "react";
import { TrashIcon, UploadIcon } from "@primer/octicons-react";
import { MoreVertical } from "lucide-react";
import { Button, ConfirmationModal } from "@renderer/components";
import { createPortal } from "react-dom";
import { useContext, useEffect, useRef, useState } from "react";
import { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
@@ -9,16 +11,33 @@ import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile");
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } =
useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast();
const handleChangeCoverClick = async () => {
const hasBanner = !!userProfile?.backgroundImageUrl;
const closeMenu = () => {
setIsMenuClosing(true);
setTimeout(() => {
setIsMenuOpen(false);
setIsMenuClosing(false);
}, 150);
};
const handleReplaceBanner = async () => {
closeMenu();
try {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
@@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() {
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
}
} finally {
setIsUploadingBackgorundImage(false);
}
};
const handleRemoveBannerClick = () => {
closeMenu();
setShowRemoveBannerModal(true);
};
const handleRemoveBannerConfirm = async () => {
setShowRemoveBannerModal(false);
try {
setIsUploadingBackgorundImage(true);
setSelectedBackgroundImage("");
await patchUser({ backgroundImageUrl: null });
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
} finally {
setIsUploadingBackgorundImage(false);
}
};
// Handle click outside, scroll, and escape key to close menu
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
buttonRef.current &&
!buttonRef.current.contains(target)
) {
closeMenu();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleScroll = () => {
closeMenu();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("scroll", handleScroll, true);
};
}, [isMenuOpen]);
if (!isMe || !hasActiveSubscription) return null;
return (
<Button
theme="outline"
className="upload-background-image-button"
onClick={handleChangeCoverClick}
disabled={isUploadingBackgroundImage}
// If no banner exists, show the original upload button
if (!hasBanner) {
return (
<div className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button>
</div>
);
}
// Calculate menu position
const getMenuPosition = () => {
if (!buttonRef.current) return { top: 0, right: 0 };
const rect = buttonRef.current.getBoundingClientRect();
return {
top: rect.bottom + 5,
right: window.innerWidth - rect.right,
};
};
const menuPosition = isMenuOpen ? getMenuPosition() : { top: 0, right: 0 };
const menuContent = isMenuOpen && (
<div
ref={menuRef}
className={`upload-background-image-button__menu ${
isMenuClosing ? "upload-background-image-button__menu--closing" : ""
}`}
style={{
position: "fixed",
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
}}
>
<UploadIcon />
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")}
</Button>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon size={16} />
{t("replace_banner")}
</button>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleRemoveBannerClick}
disabled={isUploadingBackgroundImage}
>
<TrashIcon size={16} />
{t("remove_banner")}
</button>
</div>
);
return (
<>
<div ref={buttonRef} className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
disabled={isUploadingBackgroundImage}
>
{t("change_banner")}
<MoreVertical size={16} />
</Button>
</div>
{createPortal(menuContent, document.body)}
<ConfirmationModal
visible={showRemoveBannerModal}
title={t("remove_banner_modal_title")}
descriptionText={t("remove_banner_confirmation")}
onClose={() => setShowRemoveBannerModal(false)}
onConfirm={handleRemoveBannerConfirm}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
buttonsIsDisabled={isUploadingBackgroundImage}
/>
</>
);
}

View File

@@ -14,10 +14,6 @@
&__section {
display: flex;
flex-direction: column;
&:not(:last-child) {
margin-bottom: calc(globals.$spacing-unit * 2);
}
}
&__section-header {

View File

@@ -18,6 +18,13 @@
align-self: flex-start;
}
&__disabled-hint {
font-size: 13px;
color: globals.$muted-color;
margin-top: calc(globals.$spacing-unit * -1);
font-style: italic;
}
&__volume-control {
display: flex;
flex-direction: column;

View File

@@ -37,6 +37,12 @@ export function SettingsGeneral() {
(state) => state.userPreferences.value
);
const lastPacket = useAppSelector((state) => state.download.lastPacket);
const hasActiveDownload =
lastPacket !== null &&
lastPacket.progress < 1 &&
!lastPacket.isDownloadingMetadata;
const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
@@ -53,6 +59,7 @@ export function SettingsGeneral() {
achievementSoundVolume: 15,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
useNativeHttpDownloader: true,
});
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
@@ -131,6 +138,8 @@ export function SettingsGeneral() {
friendStartGameNotificationsEnabled:
userPreferences.friendStartGameNotificationsEnabled ?? true,
language: language ?? "en",
useNativeHttpDownloader:
userPreferences.useNativeHttpDownloader ?? true,
}));
}
}, [userPreferences, defaultDownloadsPath]);
@@ -248,6 +257,25 @@ export function SettingsGeneral() {
}))}
/>
<h2 className="settings-general__section-title">{t("downloads")}</h2>
<CheckboxField
label={t("use_native_http_downloader")}
checked={form.useNativeHttpDownloader}
disabled={hasActiveDownload}
onChange={() =>
handleChange({
useNativeHttpDownloader: !form.useNativeHttpDownloader,
})
}
/>
{hasActiveDownload && (
<p className="settings-general__disabled-hint">
{t("cannot_change_downloader_while_downloading")}
</p>
)}
<h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField

View File

@@ -18,7 +18,7 @@ $active-opacity: 0.7;
$spacing-unit: 8px;
$toast-z-index: 5;
$toast-z-index: 150;
$bottom-panel-z-index: 3;
$title-bar-z-index: 4;
$backdrop-z-index: 4;

View File

@@ -3,13 +3,14 @@ export enum Downloader {
Torrent,
Gofile,
PixelDrain,
Qiwi,
Datanodes,
Mediafire,
TorBox,
Hydra,
Buzzheavier,
FuckingFast,
VikingFile,
Rootz,
}
export enum DownloadSourceStatus {

View File

@@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
if (uri.startsWith("https://www.mediafire.com"))
return [Downloader.Mediafire];
@@ -124,6 +123,12 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://fuckingfast.co")) {
return [Downloader.FuckingFast];
}
if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile];
}
if (uri.startsWith("https://www.rootz.so")) {
return [Downloader.Rootz];
}
if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid];

View File

@@ -20,6 +20,7 @@ export interface GameRepack {
title: string;
fileSize: string | null;
uris: string[];
unavailableUris: string[];
uploadDate: string | null;
downloadSourceId: string;
downloadSourceName: string;
@@ -187,6 +188,7 @@ export interface UserDetails {
profileVisibility: ProfileVisibility;
bio: string;
featurebaseJwt: string;
workwondersJwt: string;
subscription: Subscription | null;
karma: number;
quirks?: {

View File

@@ -128,6 +128,7 @@ export interface UserPreferences {
autoplayGameTrailers?: boolean;
hideToTrayOnGameStart?: boolean;
enableNewDownloadOptionsBadges?: boolean;
useNativeHttpDownloader?: boolean;
}
export interface ScreenState {

View File

@@ -2203,14 +2203,15 @@
tslib "^2.6.2"
"@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.3.1.tgz#f1a0ed6faa52377909440002e1632be9fc901840"
integrity sha512-tWDwrWy37CDVGeaP8AIGZPFL2RoFtmd5Y+nTzLw5qroXNedT2S66EY2d+XzB1zxulCd6nfDXnAQu4auq90aj5Q==
version "4.4.5"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.5.tgz#35e792b6db00887bdd029df9b41780ca005d064b"
integrity sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==
dependencies:
"@smithy/node-config-provider" "^4.3.1"
"@smithy/types" "^4.7.0"
"@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.11.0"
"@smithy/util-config-provider" "^4.2.0"
"@smithy/util-middleware" "^4.2.1"
"@smithy/util-endpoints" "^3.2.7"
"@smithy/util-middleware" "^4.2.7"
tslib "^2.6.2"
"@smithy/core@^3.15.0", "@smithy/core@^3.16.0":
@@ -2421,6 +2422,16 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/node-config-provider@^4.3.7":
version "4.3.7"
resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz#c023fa857b008c314f621fb5b124724c157b2fd3"
integrity sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==
dependencies:
"@smithy/property-provider" "^4.2.7"
"@smithy/shared-ini-file-loader" "^4.4.2"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83"
@@ -2440,6 +2451,14 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/property-provider@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.7.tgz#cd0044e13495cf4064b3a6ed3299e5f549ba7513"
integrity sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18"
@@ -2480,6 +2499,14 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/shared-ini-file-loader@^4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz#8fa1b459de485b11185fe8c64182e3205a280ba9"
integrity sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/signature-v4@^5.3.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4"
@@ -2507,6 +2534,13 @@
"@smithy/util-stream" "^4.5.1"
tslib "^2.6.2"
"@smithy/types@^4.11.0":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.11.0.tgz#c02f6184dcb47c4f0b387a32a7eca47956cc09f1"
integrity sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==
dependencies:
tslib "^2.6.2"
"@smithy/types@^4.6.0", "@smithy/types@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f"
@@ -2601,6 +2635,15 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/util-endpoints@^3.2.7":
version "3.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz#78cd5dd4aac8d9977f49d256d1e3418a09cade72"
integrity sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==
dependencies:
"@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-hex-encoding@^4.2.0":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b"
@@ -2616,6 +2659,14 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/util-middleware@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.7.tgz#1cae2c4fd0389ac858d29f7170c33b4443e83524"
integrity sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521"
@@ -6354,6 +6405,11 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies:
json-buffer "3.0.1"
ky@^1.11.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.14.1.tgz#16f20b3bf3939abcc04e2a9613f47360fe5f64c9"
integrity sha512-hYje4L9JCmpEQBtudo+v52X5X8tgWXUYyPcxKSuxQNboqufecl9VMWjGiucAFH060AwPXHZuH+WB2rrqfkmafw==
language-subtag-registry@^0.3.20:
version "0.3.23"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
@@ -9123,6 +9179,13 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
workwonders-sdk@0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/workwonders-sdk/-/workwonders-sdk-0.0.10.tgz#377167370a39c905c5228f8972c37c19004b7b21"
integrity sha512-bnswhlLRz1TCiqGV8l+VEOBej7u1SAkzLMEv6A60Sp0+S4j4pnmSve92KeOts/GYtUeNDuNM7fLPwZwMKY3sAg==
dependencies:
ky "^1.11.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"