mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 22:06:17 +00:00
Compare commits
79 Commits
fix/librar
...
feat/LBX-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7b9e2a0b | ||
|
|
3b3926156e | ||
|
|
0089e08ba7 | ||
|
|
bced12d077 | ||
|
|
f8ba72a0e2 | ||
|
|
2029f861f6 | ||
|
|
46e248c62a | ||
|
|
dba8f9fb22 | ||
|
|
b565ef7f00 | ||
|
|
9298d9aa09 | ||
|
|
5b05fc2644 | ||
|
|
605d064ec0 | ||
|
|
c0956b1bc1 | ||
|
|
2e152d321e | ||
|
|
a553b049ba | ||
|
|
467b27baa3 | ||
|
|
4342a4a5d5 | ||
|
|
d9d443ee6d | ||
|
|
a912b57ccc | ||
|
|
447c146035 | ||
|
|
39ff44f9d1 | ||
|
|
dbe101b7df | ||
|
|
5e4e03a958 | ||
|
|
da0ae54b60 | ||
|
|
562e30eecf | ||
|
|
e7a62c16fa | ||
|
|
ed044d797f | ||
|
|
ed3cce160f | ||
|
|
c67b275657 | ||
|
|
a2e866317d | ||
|
|
a7c82de4a7 | ||
|
|
027761a1b5 | ||
|
|
ca2f70aede | ||
|
|
2b3a8bf6b6 | ||
|
|
81b3ad3612 | ||
|
|
8f477072ba | ||
|
|
569700e85c | ||
|
|
4975f2def9 | ||
|
|
77af7509ac | ||
|
|
f37ccbb4c0 | ||
|
|
feb8d78e01 | ||
|
|
44f39d94c4 | ||
|
|
e618d313b3 | ||
|
|
7eec87c192 | ||
|
|
2ccc93ea61 | ||
|
|
7e7390885e | ||
|
|
64815f4f8d | ||
|
|
4dfdc4d798 | ||
|
|
9bbfab2aff | ||
|
|
01938f8905 | ||
|
|
f60ad5908d | ||
|
|
fe6553bcdc | ||
|
|
87895bb715 | ||
|
|
290209f372 | ||
|
|
87fcbaa56e | ||
|
|
c32ce14630 | ||
|
|
e52f10a5ff | ||
|
|
bcdbe31596 | ||
|
|
7ed514b6ef | ||
|
|
42386ae0b5 | ||
|
|
04d8f900a6 | ||
|
|
8b3bcd88b1 | ||
|
|
b2bffeb2b0 | ||
|
|
0a194eaa29 | ||
|
|
07c277c033 | ||
|
|
345696ad06 | ||
|
|
6c4e8c406f | ||
|
|
c46a1e7848 | ||
|
|
590e09a8c3 | ||
|
|
c1d7ea27f3 | ||
|
|
15dbd3b2ad | ||
|
|
4584783f44 | ||
|
|
765ec70dd0 | ||
|
|
de483da51c | ||
|
|
2bc0266775 | ||
|
|
c9729fb3eb | ||
|
|
9a7ad148e3 | ||
|
|
d929fbaeaa | ||
|
|
8fa33119d6 |
@@ -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=
|
||||
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -57,6 +57,7 @@ jobs:
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
MAIN_VITE_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 }}
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -54,9 +54,10 @@ jobs:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
MAIN_VITE_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 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.7.6",
|
||||
"version": "3.8.0",
|
||||
"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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import aria2p
|
||||
from aria2p.client import ClientException as DownloadNotFound
|
||||
|
||||
class HttpMultiLinkDownloader:
|
||||
def __init__(self):
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
self.total_size = None
|
||||
self.aria2 = aria2p.API(
|
||||
aria2p.Client(
|
||||
host="http://localhost",
|
||||
port=6800,
|
||||
secret=""
|
||||
)
|
||||
)
|
||||
|
||||
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
|
||||
"""Add multiple URLs to download queue with same options"""
|
||||
options = {"dir": save_path}
|
||||
if header:
|
||||
options["header"] = header
|
||||
if out:
|
||||
options["out"] = out
|
||||
|
||||
# Clear any existing downloads first
|
||||
self.cancel_download()
|
||||
self.completed_downloads = []
|
||||
self.total_size = total_size
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
added_downloads = self.aria2.add(url, options=options)
|
||||
self.downloads.extend(added_downloads)
|
||||
except Exception as e:
|
||||
print(f"Error adding download for URL {url}: {str(e)}")
|
||||
|
||||
def pause_download(self):
|
||||
"""Pause all active downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
self.aria2.pause(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error pausing downloads: {str(e)}")
|
||||
|
||||
def cancel_download(self):
|
||||
"""Cancel and remove all downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
# First try to stop the downloads
|
||||
self.aria2.remove(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error removing downloads: {str(e)}")
|
||||
finally:
|
||||
# Clear the downloads list regardless of success/failure
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
|
||||
def get_download_status(self):
|
||||
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
|
||||
if not self.downloads and not self.completed_downloads:
|
||||
return []
|
||||
|
||||
total_completed = 0
|
||||
current_download_speed = 0
|
||||
active_downloads = []
|
||||
to_remove = []
|
||||
|
||||
# First calculate sizes from completed downloads
|
||||
for completed in self.completed_downloads:
|
||||
total_completed += completed['size']
|
||||
|
||||
# Then check active downloads
|
||||
for download in self.downloads:
|
||||
try:
|
||||
current_download = self.aria2.get_download(download.gid)
|
||||
|
||||
# Skip downloads that are not properly initialized
|
||||
if not current_download or not current_download.files:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
|
||||
# Add to completed size and speed calculations
|
||||
total_completed += current_download.completed_length
|
||||
current_download_speed += current_download.download_speed
|
||||
|
||||
# If download is complete, move it to completed_downloads
|
||||
if current_download.status == 'complete':
|
||||
self.completed_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length
|
||||
})
|
||||
to_remove.append(download)
|
||||
else:
|
||||
active_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length,
|
||||
'completed': current_download.completed_length,
|
||||
'speed': current_download.download_speed
|
||||
})
|
||||
|
||||
except DownloadNotFound:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error getting download status: {str(e)}")
|
||||
continue
|
||||
|
||||
# Clean up completed/removed downloads from active list
|
||||
for download in to_remove:
|
||||
try:
|
||||
if download in self.downloads:
|
||||
self.downloads.remove(download)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return aggregate status
|
||||
if self.total_size or active_downloads or self.completed_downloads:
|
||||
# Use the first active download's name as the folder name, or completed if none active
|
||||
folder_name = None
|
||||
if active_downloads:
|
||||
folder_name = active_downloads[0]['name']
|
||||
elif self.completed_downloads:
|
||||
folder_name = self.completed_downloads[0]['name']
|
||||
|
||||
if folder_name and '/' in folder_name:
|
||||
folder_name = folder_name.split('/')[0]
|
||||
|
||||
# Use provided total size if available, otherwise sum from downloads
|
||||
total_size = self.total_size
|
||||
if not total_size:
|
||||
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
|
||||
|
||||
# Calculate completion status based on total downloaded vs total size
|
||||
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
|
||||
|
||||
# If all downloads are complete, clear the completed_downloads list to prevent status updates
|
||||
if is_complete:
|
||||
self.completed_downloads = []
|
||||
|
||||
return [{
|
||||
'folderName': folder_name,
|
||||
'fileSize': total_size,
|
||||
'progress': total_completed / total_size if total_size > 0 else 0,
|
||||
'downloadSpeed': current_download_speed,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'status': 'complete' if is_complete else 'active',
|
||||
'bytesDownloaded': total_completed,
|
||||
}]
|
||||
|
||||
return []
|
||||
@@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil
|
||||
from torrent_downloader import TorrentDownloader
|
||||
from 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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -182,6 +182,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",
|
||||
@@ -651,6 +657,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 +668,16 @@
|
||||
"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",
|
||||
"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 +701,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 +729,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 +786,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -172,6 +172,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",
|
||||
@@ -654,6 +660,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 +673,16 @@
|
||||
"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",
|
||||
"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 +706,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 +739,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 +795,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,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": "Открыть папку",
|
||||
@@ -651,6 +657,7 @@
|
||||
"sending": "Отправка",
|
||||
"friend_request_sent": "Запрос в друзья отправлен",
|
||||
"friends": "Друзья",
|
||||
"badges": "Значки",
|
||||
"friends_list": "Список друзей",
|
||||
"user_not_found": "Пользователь не найден",
|
||||
"block_user": "Заблокировать пользователя",
|
||||
@@ -661,12 +668,16 @@
|
||||
"ignore_request": "Игнорировать запрос",
|
||||
"cancel_request": "Отменить запрос",
|
||||
"undo_friendship": "Удалить друга",
|
||||
"friendship_removed": "Друг удален",
|
||||
"request_accepted": "Запрос принят",
|
||||
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
||||
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
||||
"blocked_users": "Заблокированные пользователи",
|
||||
"unblock": "Разблокировать",
|
||||
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
||||
"view_all": "Показать все",
|
||||
"load_more": "Загрузить еще",
|
||||
"loading": "Загрузка",
|
||||
"pending": "Ожидание",
|
||||
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
||||
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
||||
@@ -690,8 +701,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 +728,9 @@
|
||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||
"karma": "Карма",
|
||||
"karma_count": "карма",
|
||||
"karma_description": "Заработана положительными оценками отзывов",
|
||||
"user_reviews": "Отзывы",
|
||||
"loading_reviews": "Загрузка отзывов...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"no_reviews": "Пока нет отзывов",
|
||||
"delete_review": "Удалить отзыв"
|
||||
},
|
||||
@@ -764,5 +783,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": "Запрос в друзья отклонен"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -668,8 +668,7 @@
|
||||
"game_removed_from_pinned": "Гру видалено із закріплених",
|
||||
"game_added_to_pinned": "Гру додано до закріплених",
|
||||
"karma": "Карма",
|
||||
"karma_count": "карма",
|
||||
"karma_description": "Зароблена позитивними оцінками на відгуках"
|
||||
"karma_count": "карма"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Досягнення розблоковано",
|
||||
|
||||
@@ -689,7 +689,6 @@
|
||||
"game_removed_from_pinned": "游戏已从置顶移除",
|
||||
"karma": "业力",
|
||||
"karma_count": "业力值",
|
||||
"karma_description": "通过评论获得的点赞",
|
||||
"loading_reviews": "正在加载评价...",
|
||||
"manual_playtime_tooltip": "该游戏时长已手动更新",
|
||||
"pinned": "已置顶",
|
||||
|
||||
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadsSublevel, levelKeys } from "@main/level";
|
||||
import { GameShop } from "@types";
|
||||
|
||||
const getGameInstallerActionType = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
): Promise<"install" | "open-folder"> => {
|
||||
const downloadKey = levelKeys.game(shop, objectId);
|
||||
const download = await downloadsSublevel.get(downloadKey);
|
||||
|
||||
if (!download?.folderName) return "open-folder";
|
||||
|
||||
const gamePath = path.join(
|
||||
download.downloadPath ?? (await getDownloadsPath()),
|
||||
download.folderName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gamePath)) {
|
||||
await downloadsSublevel.del(downloadKey);
|
||||
return "open-folder";
|
||||
}
|
||||
|
||||
// macOS always opens folder
|
||||
if (process.platform === "darwin") {
|
||||
return "open-folder";
|
||||
}
|
||||
|
||||
// If path is a file, it will show in folder (open-folder behavior)
|
||||
if (fs.lstatSync(gamePath).isFile()) {
|
||||
return "open-folder";
|
||||
}
|
||||
|
||||
// Check for setup.exe
|
||||
const setupPath = path.join(gamePath, "setup.exe");
|
||||
if (fs.existsSync(setupPath)) {
|
||||
return "install";
|
||||
}
|
||||
|
||||
// Check if there's exactly one .exe file
|
||||
const gamePathFileNames = fs.readdirSync(gamePath);
|
||||
const gamePathExecutableFiles = gamePathFileNames.filter(
|
||||
(fileName: string) => path.extname(fileName).toLowerCase() === ".exe"
|
||||
);
|
||||
|
||||
if (gamePathExecutableFiles.length === 1) {
|
||||
return "install";
|
||||
}
|
||||
|
||||
// Otherwise, opens folder
|
||||
return "open-folder";
|
||||
};
|
||||
|
||||
registerEvent("getGameInstallerActionType", getGameInstallerActionType);
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./real-debrid";
|
||||
export * from "./torbox";
|
||||
export * from "./js-http-downloader";
|
||||
|
||||
380
src/main/services/download/js-http-downloader.ts
Normal file
380
src/main/services/download/js-http-downloader.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export interface JsHttpDownloaderStatus {
|
||||
folderName: string;
|
||||
fileSize: number;
|
||||
progress: number;
|
||||
downloadSpeed: number;
|
||||
numPeers: number;
|
||||
numSeeds: number;
|
||||
status: "active" | "paused" | "complete" | "error";
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
export interface JsHttpDownloaderOptions {
|
||||
url: string;
|
||||
savePath: string;
|
||||
filename?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class JsHttpDownloader {
|
||||
private abortController: AbortController | null = null;
|
||||
private writeStream: fs.WriteStream | null = null;
|
||||
private currentOptions: JsHttpDownloaderOptions | null = null;
|
||||
|
||||
private bytesDownloaded = 0;
|
||||
private fileSize = 0;
|
||||
private downloadSpeed = 0;
|
||||
private status: "active" | "paused" | "complete" | "error" = "paused";
|
||||
private folderName = "";
|
||||
private lastSpeedUpdate = Date.now();
|
||||
private bytesAtLastSpeedUpdate = 0;
|
||||
private isDownloading = false;
|
||||
|
||||
async startDownload(options: JsHttpDownloaderOptions): Promise<void> {
|
||||
if (this.isDownloading) {
|
||||
logger.log(
|
||||
"[JsHttpDownloader] Download already in progress, resuming..."
|
||||
);
|
||||
return this.resumeDownload();
|
||||
}
|
||||
|
||||
this.currentOptions = options;
|
||||
this.abortController = new AbortController();
|
||||
this.status = "active";
|
||||
this.isDownloading = true;
|
||||
|
||||
const { url, savePath, filename, headers = {} } = options;
|
||||
const { filePath, startByte, usedFallback } = this.prepareDownloadPath(
|
||||
savePath,
|
||||
filename,
|
||||
url
|
||||
);
|
||||
const requestHeaders = this.buildRequestHeaders(headers, startByte);
|
||||
|
||||
try {
|
||||
await this.executeDownload(
|
||||
url,
|
||||
requestHeaders,
|
||||
filePath,
|
||||
startByte,
|
||||
savePath,
|
||||
usedFallback
|
||||
);
|
||||
} catch (err) {
|
||||
this.handleDownloadError(err as Error);
|
||||
} finally {
|
||||
this.isDownloading = false;
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private prepareDownloadPath(
|
||||
savePath: string,
|
||||
filename: string | undefined,
|
||||
url: string
|
||||
): { filePath: string; startByte: number; usedFallback: boolean } {
|
||||
const extractedFilename = filename || this.extractFilename(url);
|
||||
const usedFallback = !extractedFilename;
|
||||
const resolvedFilename = extractedFilename || "download";
|
||||
this.folderName = resolvedFilename;
|
||||
const filePath = path.join(savePath, resolvedFilename);
|
||||
|
||||
if (!fs.existsSync(savePath)) {
|
||||
fs.mkdirSync(savePath, { recursive: true });
|
||||
}
|
||||
|
||||
let startByte = 0;
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stats = fs.statSync(filePath);
|
||||
startByte = stats.size;
|
||||
this.bytesDownloaded = startByte;
|
||||
logger.log(`[JsHttpDownloader] Resuming download from byte ${startByte}`);
|
||||
}
|
||||
|
||||
this.resetSpeedTracking();
|
||||
return { filePath, startByte, usedFallback };
|
||||
}
|
||||
|
||||
private buildRequestHeaders(
|
||||
headers: Record<string, string>,
|
||||
startByte: number
|
||||
): Record<string, string> {
|
||||
const requestHeaders: Record<string, string> = { ...headers };
|
||||
if (startByte > 0) {
|
||||
requestHeaders["Range"] = `bytes=${startByte}-`;
|
||||
}
|
||||
return requestHeaders;
|
||||
}
|
||||
|
||||
private resetSpeedTracking(): void {
|
||||
this.lastSpeedUpdate = Date.now();
|
||||
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
|
||||
this.downloadSpeed = 0;
|
||||
}
|
||||
|
||||
private parseFileSize(response: Response, startByte: number): void {
|
||||
const contentRange = response.headers.get("content-range");
|
||||
if (contentRange) {
|
||||
const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange);
|
||||
if (match) {
|
||||
this.fileSize = Number.parseInt(match[1], 10);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
this.fileSize = startByte + Number.parseInt(contentLength, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeDownload(
|
||||
url: string,
|
||||
requestHeaders: Record<string, string>,
|
||||
filePath: string,
|
||||
startByte: number,
|
||||
savePath: string,
|
||||
usedFallback: boolean
|
||||
): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
headers: requestHeaders,
|
||||
signal: this.abortController?.signal,
|
||||
});
|
||||
|
||||
// Handle 416 Range Not Satisfiable - existing file is larger than server file
|
||||
// This happens when downloading same game from different source
|
||||
if (response.status === 416 && startByte > 0) {
|
||||
logger.log(
|
||||
"[JsHttpDownloader] Range not satisfiable, deleting existing file and restarting"
|
||||
);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
this.bytesDownloaded = 0;
|
||||
this.resetSpeedTracking();
|
||||
|
||||
// Retry without Range header
|
||||
const headersWithoutRange = { ...requestHeaders };
|
||||
delete headersWithoutRange["Range"];
|
||||
|
||||
return this.executeDownload(
|
||||
url,
|
||||
headersWithoutRange,
|
||||
filePath,
|
||||
0,
|
||||
savePath,
|
||||
usedFallback
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
this.parseFileSize(response, startByte);
|
||||
|
||||
// If we used "download" fallback, try to get filename from Content-Disposition
|
||||
let actualFilePath = filePath;
|
||||
if (usedFallback && startByte === 0) {
|
||||
const headerFilename = this.parseContentDisposition(response);
|
||||
if (headerFilename) {
|
||||
actualFilePath = path.join(savePath, headerFilename);
|
||||
this.folderName = headerFilename;
|
||||
logger.log(
|
||||
`[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
const flags = startByte > 0 ? "a" : "w";
|
||||
this.writeStream = fs.createWriteStream(actualFilePath, { flags });
|
||||
|
||||
const readableStream = this.createReadableStream(response.body.getReader());
|
||||
await pipeline(readableStream, this.writeStream);
|
||||
|
||||
this.status = "complete";
|
||||
this.downloadSpeed = 0;
|
||||
logger.log("[JsHttpDownloader] Download complete");
|
||||
}
|
||||
|
||||
private parseContentDisposition(response: Response): string | undefined {
|
||||
const header = response.headers.get("content-disposition");
|
||||
if (!header) return undefined;
|
||||
|
||||
// Try to extract filename from Content-Disposition header
|
||||
// Formats: attachment; filename="file.zip" or attachment; filename=file.zip
|
||||
const filenameMatch = /filename\*?=['"]?(?:UTF-8'')?([^"';\n]+)['"]?/i.exec(
|
||||
header
|
||||
);
|
||||
if (filenameMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(filenameMatch[1].trim());
|
||||
} catch {
|
||||
return filenameMatch[1].trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private createReadableStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
): Readable {
|
||||
const onChunk = (length: number) => {
|
||||
this.bytesDownloaded += length;
|
||||
this.updateSpeed();
|
||||
};
|
||||
|
||||
return new Readable({
|
||||
read() {
|
||||
reader
|
||||
.read()
|
||||
.then(({ done, value }) => {
|
||||
if (done) {
|
||||
this.push(null);
|
||||
return;
|
||||
}
|
||||
onChunk(value.length);
|
||||
this.push(Buffer.from(value));
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (err.name === "AbortError") {
|
||||
this.push(null);
|
||||
} else {
|
||||
this.destroy(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleDownloadError(err: Error): void {
|
||||
// Handle abort/cancellation errors - these are expected when user pauses/cancels
|
||||
if (
|
||||
err.name === "AbortError" ||
|
||||
(err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE"
|
||||
) {
|
||||
logger.log("[JsHttpDownloader] Download aborted");
|
||||
this.status = "paused";
|
||||
} else {
|
||||
logger.error("[JsHttpDownloader] Download error:", err);
|
||||
this.status = "error";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async resumeDownload(): Promise<void> {
|
||||
if (!this.currentOptions) {
|
||||
throw new Error("No download options available for resume");
|
||||
}
|
||||
this.isDownloading = false;
|
||||
await this.startDownload(this.currentOptions);
|
||||
}
|
||||
|
||||
pauseDownload(): void {
|
||||
if (this.abortController) {
|
||||
logger.log("[JsHttpDownloader] Pausing download");
|
||||
this.abortController.abort();
|
||||
this.status = "paused";
|
||||
this.downloadSpeed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
cancelDownload(deleteFile = true): void {
|
||||
if (this.abortController) {
|
||||
logger.log("[JsHttpDownloader] Cancelling download");
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
|
||||
if (deleteFile && this.currentOptions && this.status !== "complete") {
|
||||
const filePath = path.join(this.currentOptions.savePath, this.folderName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.log("[JsHttpDownloader] Deleted partial file");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"[JsHttpDownloader] Failed to delete partial file:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
getDownloadStatus(): JsHttpDownloaderStatus | null {
|
||||
if (!this.currentOptions && this.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
folderName: this.folderName,
|
||||
fileSize: this.fileSize,
|
||||
progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0,
|
||||
downloadSpeed: this.downloadSpeed,
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
status: this.status,
|
||||
bytesDownloaded: this.bytesDownloaded,
|
||||
};
|
||||
}
|
||||
|
||||
private updateSpeed(): void {
|
||||
const now = Date.now();
|
||||
const elapsed = (now - this.lastSpeedUpdate) / 1000;
|
||||
|
||||
if (elapsed >= 1) {
|
||||
const bytesDelta = this.bytesDownloaded - this.bytesAtLastSpeedUpdate;
|
||||
this.downloadSpeed = bytesDelta / elapsed;
|
||||
this.lastSpeedUpdate = now;
|
||||
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private extractFilename(url: string): string | undefined {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const pathParts = pathname.split("/");
|
||||
const filename = pathParts.at(-1);
|
||||
|
||||
if (filename?.includes(".") && filename.length > 0) {
|
||||
return decodeURIComponent(filename);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.writeStream) {
|
||||
this.writeStream.close();
|
||||
this.writeStream = null;
|
||||
}
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.currentOptions = null;
|
||||
this.bytesDownloaded = 0;
|
||||
this.fileSize = 0;
|
||||
this.downloadSpeed = 0;
|
||||
this.status = "paused";
|
||||
this.folderName = "";
|
||||
this.isDownloading = false;
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { requestWebPage } from "@main/helpers";
|
||||
|
||||
export class QiwiApi {
|
||||
public static async getDownloadUrl(url: string) {
|
||||
const document = await requestWebPage(url);
|
||||
const fileName = document.querySelector("h1")?.textContent;
|
||||
|
||||
const slug = url.split("/").pop();
|
||||
const extension = fileName?.split(".").pop();
|
||||
|
||||
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
|
||||
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
58
src/main/services/hosters/rootz.ts
Normal file
58
src/main/services/hosters/rootz.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface RootzApiResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
url: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
expiresIn: number;
|
||||
expiresAt: string | null;
|
||||
downloads: number;
|
||||
canDelete: boolean;
|
||||
fileId: string;
|
||||
isMirrored: boolean;
|
||||
sourceService: string | null;
|
||||
adsEnabled: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class RootzApi {
|
||||
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (pathSegments.length < 2 || pathSegments[0] !== "d") {
|
||||
throw new Error("Invalid rootz URL format");
|
||||
}
|
||||
|
||||
const id = pathSegments[1];
|
||||
const apiUrl = `https://www.rootz.so/api/files/download-by-short/${id}`;
|
||||
|
||||
const response = await axios.get<RootzApiResponse>(apiUrl);
|
||||
|
||||
if (response.data.success && response.data.data?.url) {
|
||||
return response.data.data.url;
|
||||
}
|
||||
|
||||
throw new Error("Failed to get download URL from rootz API");
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError<RootzApiResponse>;
|
||||
if (axiosError.response?.status === 404) {
|
||||
const errorMessage =
|
||||
axiosError.response.data?.error || "File not found";
|
||||
logger.error(`[Rootz] ${errorMessage}`);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("[Rootz] Error fetching download URL:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/main/services/hosters/vikingfile.ts
Normal file
46
src/main/services/hosters/vikingfile.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from "axios";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface UnlockResponse {
|
||||
link: string;
|
||||
hoster: string;
|
||||
}
|
||||
|
||||
export class VikingFileApi {
|
||||
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||
const unlockResponse = await axios.post<UnlockResponse>(
|
||||
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
|
||||
{ url: uri }
|
||||
);
|
||||
|
||||
if (!unlockResponse.data.link) {
|
||||
throw new Error("Failed to unlock VikingFile URL");
|
||||
}
|
||||
|
||||
const redirectUrl = unlockResponse.data.link;
|
||||
|
||||
try {
|
||||
const redirectResponse = await axios.head(redirectUrl, {
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) =>
|
||||
status === 301 || status === 302 || status === 200,
|
||||
});
|
||||
|
||||
if (
|
||||
redirectResponse.headers.location ||
|
||||
redirectResponse.status === 301 ||
|
||||
redirectResponse.status === 302
|
||||
) {
|
||||
return redirectResponse.headers.location || redirectUrl;
|
||||
}
|
||||
|
||||
return redirectUrl;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[VikingFile] Error following redirect, using redirect URL:`,
|
||||
error
|
||||
);
|
||||
return redirectUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||
readonly MAIN_VITE_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;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -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: (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -511,6 +511,13 @@
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
&__simple-action-btn {
|
||||
padding: calc(globals.$spacing-unit);
|
||||
min-height: unset;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
&__progress-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -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];
|
||||
@@ -721,7 +760,7 @@ export function DownloadGroup({
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => {
|
||||
cancelDownload(game.shop, game.objectId);
|
||||
handleCancelClick(game.shop, game.objectId);
|
||||
},
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
@@ -746,7 +785,7 @@ export function DownloadGroup({
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => {
|
||||
cancelDownload(game.shop, game.objectId);
|
||||
handleCancelClick(game.shop, game.objectId);
|
||||
},
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
@@ -770,6 +809,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 +874,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.real-debrid-info-modal {
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2.5);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
&__description-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: globals.$body-color;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__create-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
color: #c0c1c7;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Link, Modal } from "@renderer/components";
|
||||
import { LinkExternalIcon } from "@primer/octicons-react";
|
||||
import "./real-debrid-info-modal.scss";
|
||||
|
||||
const realDebridReferralId = import.meta.env
|
||||
.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID;
|
||||
|
||||
const REAL_DEBRID_URL = realDebridReferralId
|
||||
? `https://real-debrid.com/?id=${realDebridReferralId}`
|
||||
: "https://real-debrid.com";
|
||||
|
||||
export interface RealDebridInfoModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RealDebridInfoModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: Readonly<RealDebridInfoModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { t: tSettings } = useTranslation("settings");
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={tSettings("enable_real_debrid")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="real-debrid-info-modal__content">
|
||||
<div className="real-debrid-info-modal__description-container">
|
||||
<p className="real-debrid-info-modal__description">
|
||||
{tSettings("real_debrid_description")}
|
||||
</p>
|
||||
<Link
|
||||
to={REAL_DEBRID_URL}
|
||||
className="real-debrid-info-modal__create-account"
|
||||
>
|
||||
<LinkExternalIcon />
|
||||
{tSettings("create_real_debrid_account")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate("/settings?tab=4");
|
||||
}}
|
||||
>
|
||||
{t("go_to_settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,13 +3,14 @@ export enum Downloader {
|
||||
Torrent,
|
||||
Gofile,
|
||||
PixelDrain,
|
||||
Qiwi,
|
||||
Datanodes,
|
||||
Mediafire,
|
||||
TorBox,
|
||||
Hydra,
|
||||
Buzzheavier,
|
||||
FuckingFast,
|
||||
VikingFile,
|
||||
Rootz,
|
||||
}
|
||||
|
||||
export enum DownloadSourceStatus {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface UserPreferences {
|
||||
autoplayGameTrailers?: boolean;
|
||||
hideToTrayOnGameStart?: boolean;
|
||||
enableNewDownloadOptionsBadges?: boolean;
|
||||
useNativeHttpDownloader?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenState {
|
||||
|
||||
75
yarn.lock
75
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user