Compare commits

...

43 Commits

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-08 23:29:01 +00:00
Moyasee
ed044d797f refactor: streamline download preparation and status handling in DownloadManager 2026-01-07 20:35:43 +02:00
Moyasee
ed3cce160f refactor: adjust file size handling in DownloadManager to ensure accurate download status updates 2026-01-07 17:32:46 +02:00
Moyasee
c67b275657 refactor: improve download initiation and error handling in DownloadManager 2026-01-07 17:26:29 +02:00
Moyasee
a2e866317d refactor: update interruptedDownload type to Download | null for improved type safety 2026-01-06 20:01:15 +02:00
Moyasee
a7c82de4a7 refactor: enhance download management by prioritizing interrupted downloads and improving error logging 2026-01-06 19:59:52 +02:00
Moyasee
027761a1b5 refactor: update cancelDownload method to conditionally delete file based on parameter 2026-01-06 19:18:42 +02:00
Moyasee
ca2f70aede refactor: enhance filename extraction and handling in download services 2026-01-06 18:50:36 +02:00
Moyasee
2b3a8bf6b6 refactor: replace type assertions with non-null assertions for download ID in DownloadManager 2026-01-06 18:11:22 +02:00
Moyasee
81b3ad3612 refactor: update download ID extraction and improve optional chaining in download services 2026-01-06 18:05:05 +02:00
Moyasee
8f477072ba refactor: improve error handling and download path preparation in JsHttpDownloader 2026-01-06 17:56:46 +02:00
Moyasee
569700e85c refactor: streamline download status updates in DownloadManager 2026-01-06 17:47:12 +02:00
Moyasee
4975f2def9 refactor: optimize chunk handling in JsHttpDownloader 2026-01-06 17:42:42 +02:00
Moyasee
77af7509ac feat: implement native HTTP downloader option and enhance download management 2026-01-06 17:41:05 +02:00
Kiwo.2
44f39d94c4 Added new lines* 2026-01-04 14:07:34 +01:00
Kiwo.2
e618d313b3 Merge branch 'main' of https://github.com/Stormm232/hydra 2026-01-04 13:56:46 +01:00
Kiwo.2
7eec87c192 Added new lines 2026-01-04 13:56:32 +01:00
Moyasee
2ccc93ea61 feat: add empty state for friends box and new translation key 2026-01-04 04:23:59 +02:00
44 changed files with 2025 additions and 666 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.8.0", "version": "3.8.1",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",

View File

@@ -1,151 +0,0 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpMultiLinkDownloader:
def __init__(self):
self.downloads = []
self.completed_downloads = []
self.total_size = None
self.aria2 = aria2p.API(
aria2p.Client(
host="http://localhost",
port=6800,
secret=""
)
)
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
"""Add multiple URLs to download queue with same options"""
options = {"dir": save_path}
if header:
options["header"] = header
if out:
options["out"] = out
# Clear any existing downloads first
self.cancel_download()
self.completed_downloads = []
self.total_size = total_size
for url in urls:
try:
added_downloads = self.aria2.add(url, options=options)
self.downloads.extend(added_downloads)
except Exception as e:
print(f"Error adding download for URL {url}: {str(e)}")
def pause_download(self):
"""Pause all active downloads"""
if self.downloads:
try:
self.aria2.pause(self.downloads)
except Exception as e:
print(f"Error pausing downloads: {str(e)}")
def cancel_download(self):
"""Cancel and remove all downloads"""
if self.downloads:
try:
# First try to stop the downloads
self.aria2.remove(self.downloads)
except Exception as e:
print(f"Error removing downloads: {str(e)}")
finally:
# Clear the downloads list regardless of success/failure
self.downloads = []
self.completed_downloads = []
def get_download_status(self):
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
if not self.downloads and not self.completed_downloads:
return []
total_completed = 0
current_download_speed = 0
active_downloads = []
to_remove = []
# First calculate sizes from completed downloads
for completed in self.completed_downloads:
total_completed += completed['size']
# Then check active downloads
for download in self.downloads:
try:
current_download = self.aria2.get_download(download.gid)
# Skip downloads that are not properly initialized
if not current_download or not current_download.files:
to_remove.append(download)
continue
# Add to completed size and speed calculations
total_completed += current_download.completed_length
current_download_speed += current_download.download_speed
# If download is complete, move it to completed_downloads
if current_download.status == 'complete':
self.completed_downloads.append({
'name': current_download.name,
'size': current_download.total_length
})
to_remove.append(download)
else:
active_downloads.append({
'name': current_download.name,
'size': current_download.total_length,
'completed': current_download.completed_length,
'speed': current_download.download_speed
})
except DownloadNotFound:
to_remove.append(download)
continue
except Exception as e:
print(f"Error getting download status: {str(e)}")
continue
# Clean up completed/removed downloads from active list
for download in to_remove:
try:
if download in self.downloads:
self.downloads.remove(download)
except ValueError:
pass
# Return aggregate status
if self.total_size or active_downloads or self.completed_downloads:
# Use the first active download's name as the folder name, or completed if none active
folder_name = None
if active_downloads:
folder_name = active_downloads[0]['name']
elif self.completed_downloads:
folder_name = self.completed_downloads[0]['name']
if folder_name and '/' in folder_name:
folder_name = folder_name.split('/')[0]
# Use provided total size if available, otherwise sum from downloads
total_size = self.total_size
if not total_size:
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
# Calculate completion status based on total downloaded vs total size
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
# If all downloads are complete, clear the completed_downloads list to prevent status updates
if is_complete:
self.completed_downloads = []
return [{
'folderName': folder_name,
'fileSize': total_size,
'progress': total_completed / total_size if total_size > 0 else 0,
'downloadSpeed': current_download_speed,
'numPeers': 0,
'numSeeds': 0,
'status': 'complete' if is_complete else 'active',
'bytesDownloaded': total_completed,
}]
return []

View File

@@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil
from torrent_downloader import TorrentDownloader from torrent_downloader import TorrentDownloader
from http_downloader import HttpDownloader from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor from profile_image_processor import ProfileImageProcessor
from http_multi_link_downloader import HttpMultiLinkDownloader
import libtorrent as lt import libtorrent as lt
app = Flask(__name__) app = Flask(__name__)
@@ -25,15 +24,7 @@ if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload)) initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloading_game_id = initial_download['game_id'] downloading_game_id = initial_download['game_id']
if isinstance(initial_download['url'], list): if initial_download['url'].startswith('magnet'):
# Handle multiple URLs using HttpMultiLinkDownloader
http_multi_downloader = HttpMultiLinkDownloader()
downloads[initial_download['game_id']] = http_multi_downloader
try:
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
except Exception as e:
print("Error starting multi-link download", e)
elif initial_download['url'].startswith('magnet'):
torrent_downloader = TorrentDownloader(torrent_session) torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader downloads[initial_download['game_id']] = torrent_downloader
try: try:
@@ -78,14 +69,6 @@ def status():
if not status: if not status:
return jsonify(None) return jsonify(None)
if isinstance(status, list):
if not status: # Empty list
return jsonify(None)
# For multi-link downloader, use the aggregated status
# The status will already be aggregated by the HttpMultiLinkDownloader
return jsonify(status[0]), 200
return jsonify(status), 200 return jsonify(status), 200
@app.route("/seed-status", methods=["GET"]) @app.route("/seed-status", methods=["GET"])
@@ -104,21 +87,7 @@ def seed_status():
if not response: if not response:
continue continue
if isinstance(response, list): if response.get('status') == 5: # Torrent seeding check
# For multi-link downloader, check if all files are complete
if response and all(item['status'] == 'complete' for item in response):
seed_status.append({
'gameId': game_id,
'status': 'complete',
'folderName': response[0]['folderName'],
'fileSize': sum(item['fileSize'] for item in response),
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
'downloadSpeed': 0,
'numPeers': 0,
'numSeeds': 0,
'progress': 1.0
})
elif response.get('status') == 5: # Original torrent seeding check
seed_status.append({ seed_status.append({
'gameId': game_id, 'gameId': game_id,
**response, **response,
@@ -180,15 +149,7 @@ def action():
existing_downloader = downloads.get(game_id) existing_downloader = downloads.get(game_id)
if isinstance(url, list): if url.startswith('magnet'):
# Handle multiple URLs using HttpMultiLinkDownloader
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
else:
http_multi_downloader = HttpMultiLinkDownloader()
downloads[game_id] = http_multi_downloader
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
elif url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader): if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path']) existing_downloader.start_download(url, data['save_path'])
else: else:

View File

@@ -404,6 +404,10 @@
"completed": "Completed", "completed": "Completed",
"removed": "Not downloaded", "removed": "Not downloaded",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_download": "Cancel download?",
"cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.",
"keep_downloading": "No, keep downloading",
"yes_cancel": "Yes, cancel",
"filter": "Filter downloaded games", "filter": "Filter downloaded games",
"remove": "Remove", "remove": "Remove",
"downloading_metadata": "Downloading metadata…", "downloading_metadata": "Downloading metadata…",
@@ -594,7 +598,10 @@
"notification_preview": "Achievement Notification Preview", "notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game", "enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@@ -689,6 +696,7 @@
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"unblock": "Unblock", "unblock": "Unblock",
"no_friends_added": "You have no added friends", "no_friends_added": "You have no added friends",
"no_friends_yet": "You haven't added any friends yet",
"view_all": "View all", "view_all": "View all",
"load_more": "Load more", "load_more": "Load more",
"loading": "Loading", "loading": "Loading",
@@ -716,8 +724,15 @@
"profile_reported": "Profile reported", "profile_reported": "Profile reported",
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code", "copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
"replace_banner": "Replace banner",
"remove_banner": "Remove banner",
"remove_banner_modal_title": "Remove banner?",
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
"remove": "Remove",
"background_image_updated": "Background image updated", "background_image_updated": "Background image updated",
"stats": "Stats", "stats": "Stats",
"achievements": "achievements", "achievements": "achievements",
@@ -738,9 +753,7 @@
"user_reviews": "Reviews", "user_reviews": "Reviews",
"delete_review": "Delete Review", "delete_review": "Delete Review",
"loading_reviews": "Loading reviews...", "loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025"
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
}, },
"library": { "library": {
"library": "Library", "library": "Library",

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado", "game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar Sesión", "sign_in": "Iniciar Sesión",
"friends": "Amigos", "friends": "Amigos",
"notifications": "Notificaciones",
"need_help": "¿Necesitás ayuda?", "need_help": "¿Necesitás ayuda?",
"favorites": "Favoritos", "favorites": "Favoritos",
"playable_button_title": "Solo mostrar juegos que podés jugar en este momento", "playable_button_title": "Solo mostrar juegos que podés jugar en este momento",
@@ -115,6 +116,7 @@
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}", "downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…", "calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)", "checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
"extracting": "Extrayendo {{title}}… ({{percentage}} completado)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Instalación completada", "installation_complete": "Instalación completada",
"installation_complete_message": "Common redistributables instalados correctamente" "installation_complete_message": "Common redistributables instalados correctamente"
@@ -173,6 +175,7 @@
"repacks_modal_description": "Elegí el repack que querés descargar", "repacks_modal_description": "Elegí el repack que querés descargar",
"select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>", "select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
"download_now": "Descargar ahora", "download_now": "Descargar ahora",
"loading": "Cargando...",
"no_shop_details": "No se pudieron obtener detalles de la tienda.", "no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga", "download_options": "Opciones de descarga",
"download_path": "Ruta de descarga", "download_path": "Ruta de descarga",
@@ -206,6 +209,7 @@
"danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra", "danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"extracting": "Extrayendo",
"last_downloaded_option": "Última opción de descarga", "last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo", "new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam", "create_steam_shortcut": "Crear atajo de Steam",
@@ -400,6 +404,10 @@
"completed": "Completado", "completed": "Completado",
"removed": "No descargado", "removed": "No descargado",
"cancel": "Cancelar", "cancel": "Cancelar",
"cancel_download": "¿Cancelar descarga?",
"cancel_download_description": "¿Estás seguro de que querés cancelar esta descarga? Todos los archivos descargados serán eliminados.",
"keep_downloading": "No, seguir descargando",
"yes_cancel": "Sí, cancelar",
"filter": "Filtrar juegos descargados", "filter": "Filtrar juegos descargados",
"remove": "Remover", "remove": "Remover",
"downloading_metadata": "Descargando metadatos…", "downloading_metadata": "Descargando metadatos…",
@@ -420,7 +428,13 @@
"resume_seeding": "Continuar sembrando", "resume_seeding": "Continuar sembrando",
"options": "Administrar", "options": "Administrar",
"extract": "Extraer archivos", "extract": "Extraer archivos",
"extracting": "Extrayendo archivos…" "extracting": "Extrayendo archivos…",
"delete_archive_title": "¿Querés eliminar {{fileName}}?",
"delete_archive_description": "El archivo se extrajo exitosamente y ya no es necesario.",
"yes": "Sí",
"no": "No",
"network": "RED",
"peak": "PICO"
}, },
"settings": { "settings": {
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
@@ -544,6 +558,7 @@
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo", "show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
"extract_files_by_default": "Extraer archivos por defecto después de descargar", "extract_files_by_default": "Extraer archivos por defecto después de descargar",
"enable_steam_achievements": "Habilitar búsqueda de logros de Steam", "enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
"enable_new_download_options_badges": "Mostrar badges de nuevas opciones de descarga",
"achievement_custom_notification_position": "Posición de notificación de logros", "achievement_custom_notification_position": "Posición de notificación de logros",
"top-left": "Superior Izquierda", "top-left": "Superior Izquierda",
"top-center": "Superior Centro", "top-center": "Superior Centro",
@@ -570,20 +585,10 @@
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"change_achievement_sound": "Cambiar sonido de logro", "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.", "downloads": "Descargas",
"download_source_failed": "Error", "use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
"download_source_matched": "Actualizado", "cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso"
"download_source_matching": "Actualizando",
"download_source_no_information": "Sin información disponible",
"download_source_pending_matching": "Actualizando pronto",
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
"hydra_cloud": "Hydra Cloud",
"preview_sound": "Vista previa de sonido",
"remove_achievement_sound": "Eliminar sonido de logros",
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
}, },
"notifications": { "notifications": {
"download_complete": "Descarga completada", "download_complete": "Descarga completada",
@@ -675,6 +680,7 @@
"blocked_users": "Usuarios bloqueados", "blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "No tenés amistades añadidas", "no_friends_added": "No tenés amistades añadidas",
"no_friends_yet": "Aún no has agregado ningún amigo",
"view_all": "Ver todo", "view_all": "Ver todo",
"load_more": "Cargar más", "load_more": "Cargar más",
"loading": "Cargando", "loading": "Cargando",
@@ -702,8 +708,15 @@
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:", "your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad", "copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"upload_banner": "Subir banner", "upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…", "uploading_banner": "Subiendo banner…",
"change_banner": "Cambiar banner",
"replace_banner": "Reemplazar banner",
"remove_banner": "Eliminar banner",
"remove_banner_modal_title": "¿Eliminar banner?",
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
"remove": "Eliminar",
"background_image_updated": "Imagen de fondo actualizada", "background_image_updated": "Imagen de fondo actualizada",
"stats": "Estadísticas", "stats": "Estadísticas",
"achievements": "logros", "achievements": "logros",
@@ -727,8 +740,6 @@
"user_reviews": "Reseñas", "user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...", "loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Mi Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Sin reseñas aún", "no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña" "delete_review": "Eliminar reseña"
}, },

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés", "sign_in": "Bejelentkezés",
"friends": "Barátok", "friends": "Barátok",
"notifications": "Értesítések",
"need_help": "Elakadtál?", "need_help": "Elakadtál?",
"favorites": "Kedvenc Játékaim", "favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd", "playable_button_title": "Csak az azonnal játszható játékokat mutasd",
@@ -174,6 +175,7 @@
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg", "select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
"download_now": "Letöltés", "download_now": "Letöltés",
"loading": "Töltés...",
"no_shop_details": "A bolt adatai nem érhetőek el.", "no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók", "download_options": "Letöltési opciók",
"download_path": "Letöltési hely", "download_path": "Letöltési hely",
@@ -182,7 +184,13 @@
"screenshot": "Screenshot {{number}}", "screenshot": "Screenshot {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ", "open_screenshot": "{{number}} Screenshot megnyitása ",
"download_settings": "Letöltési beállítások", "download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód", "downloader": "Letöltő",
"downloader_online": "Elérhető",
"downloader_not_configured": "Elérhető de nincs beállítva",
"downloader_offline": "A link nem elérhető",
"downloader_not_available": "Nem elérhető",
"recommended": "Ajánlott",
"go_to_settings": "Beállítások megnyitása",
"select_executable": "Tallózás", "select_executable": "Tallózás",
"no_executable_selected": "Nincs futtatható fájl tallózva", "no_executable_selected": "Nincs futtatható fájl tallózva",
"open_folder": "Mappa megnyitása", "open_folder": "Mappa megnyitása",
@@ -418,9 +426,11 @@
"extract": "Fájlok kibontása", "extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…", "extracting": "Fájlok kibontása…",
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}", "delete_archive_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", "yes": "Igen",
"no": "Nem" "no": "Nem",
"network": "HÁLÓZAT",
"peak": "CSÚCS"
}, },
"settings": { "settings": {
"downloads_path": "Letöltési útvonalak", "downloads_path": "Letöltési útvonalak",
@@ -444,7 +454,7 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése", "save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve", "changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból, ennek az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés", "validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás", "remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása", "add_download_source": "Forrás hozáadása",
@@ -556,6 +566,7 @@
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése", "show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
"extract_files_by_default": "Fájlok kicsomagolása letöltés után", "extract_files_by_default": "Fájlok kicsomagolása letöltés után",
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése", "enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
"enable_new_download_options_badges": "Új letöltési helyek",
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése", "achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
"top-left": "Bal felső sarok", "top-left": "Bal felső sarok",
"top-center": "Felső közép", "top-center": "Felső közép",
@@ -636,9 +647,9 @@
"sort_by": "Rendezés:", "sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek", "achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva", "played_recently": "Nemrég játszva",
"playtime": "Játszottidő", "playtime": "Játékidő",
"total_play_time": "Teljes játszottidő", "total_play_time": "Teljes játékidő",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"no_recent_activity_title": "Hmmm… itt semmi sincs", "no_recent_activity_title": "Hmmm… itt semmi sincs",
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!", "no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
"display_name": "Profilnév", "display_name": "Profilnév",
@@ -660,6 +671,7 @@
"sending": "Küldés..", "sending": "Küldés..",
"friend_request_sent": "Barátfelkérés elküldve", "friend_request_sent": "Barátfelkérés elküldve",
"friends": "Barátok", "friends": "Barátok",
"badges": "Kitűzők",
"friends_list": "Barát lista", "friends_list": "Barát lista",
"user_not_found": "Felhasználó nem találva", "user_not_found": "Felhasználó nem találva",
"block_user": "Felhasználó letiltása", "block_user": "Felhasználó letiltása",
@@ -670,12 +682,16 @@
"ignore_request": "Kérés ignorálása", "ignore_request": "Kérés ignorálása",
"cancel_request": "Kérés visszavonása", "cancel_request": "Kérés visszavonása",
"undo_friendship": "Barát eltávolítása", "undo_friendship": "Barát eltávolítása",
"friendship_removed": "Barát eltávolítva",
"request_accepted": "Barátfelkérés elfogadva", "request_accepted": "Barátfelkérés elfogadva",
"user_blocked_successfully": "Felhasználó sikeresen letiltva", "user_blocked_successfully": "Felhasználó sikeresen letiltva",
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}", "user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
"blocked_users": "Letiltott felhasználók", "blocked_users": "Letiltott felhasználók",
"unblock": "Tiltás feloldása", "unblock": "Tiltás feloldása",
"no_friends_added": "Nincs bejelölt barátod", "no_friends_added": "Nincs bejelölt barátod",
"view_all": "Összes megtekintése",
"load_more": "Több betöltése",
"loading": "Töltés..",
"pending": "Függőben", "pending": "Függőben",
"no_pending_invites": "Nincs függőben lévő barátfelkérésed", "no_pending_invites": "Nincs függőben lévő barátfelkérésed",
"no_blocked_users": "Nincs letiltott felhasználó", "no_blocked_users": "Nincs letiltott felhasználó",
@@ -699,6 +715,7 @@
"report_reason_other": "Egyéb", "report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve", "profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:", "your_friend_code": "A barát kódod:",
"copy_friend_code": "Barátkód kimásolása",
"upload_banner": "Borítókép feltöltése", "upload_banner": "Borítókép feltöltése",
"uploading_banner": "Borítókép feltöltése…", "uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve", "background_image_updated": "Borítókép frissítve",
@@ -720,7 +737,10 @@
"karma_count": "karma", "karma_count": "karma",
"user_reviews": "Vélemények", "user_reviews": "Vélemények",
"delete_review": "Vélemény Törlése", "delete_review": "Vélemény Törlése",
"loading_reviews": "Vélemények betöltése..." "loading_reviews": "Vélemények betöltése...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Wrapped 2025 megtekintése",
"view_wrapped_button": "{{displayName}} Wrapped 2025 megtekintése"
}, },
"library": { "library": {
"library": "Könyvtár", "library": "Könyvtár",
@@ -738,7 +758,7 @@
"amount_minutes": "{{amount}} perc", "amount_minutes": "{{amount}} perc",
"amount_hours_short": "{{amount}}ó", "amount_hours_short": "{{amount}}ó",
"amount_minutes_short": "{{amount}}p", "amount_minutes_short": "{{amount}}p",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"all_games": "Összes Játék", "all_games": "Összes Játék",
"recently_played": "Nemrég Játszva", "recently_played": "Nemrég Játszva",
"favorites": "Kedvencek" "favorites": "Kedvencek"
@@ -771,5 +791,41 @@
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!", "hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
"learn_more": "Tudj meg többet", "learn_more": "Tudj meg többet",
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal" "debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
},
"notifications_page": {
"title": "Értesítések",
"mark_all_as_read": "Megjelölés olvasottként",
"clear_all": "Összes Törlése",
"loading": "Töltés..",
"empty_title": "Nincsenek értesítések",
"empty_description": "Már mindet láttad! Nézz vissza később az újdonságokért.",
"empty_filter_description": "Nincs értesítés ami megfelel ennek a szűrőnek.",
"filter_all": "Összes",
"filter_unread": "Olvasatlan",
"filter_friends": "Barátok",
"filter_badges": "Kitűzők",
"filter_upvotes": "Felpontok",
"filter_local": "Helyi",
"load_more": "Több betöltése",
"dismiss": "Eltüntetés",
"accept": "Elfogad",
"refuse": "Elutasít",
"notification": "Értesítés",
"friend_request_received_title": "Új barátkérelem!",
"friend_request_received_description": "{{displayName}} a barátod szeretne lenni",
"friend_request_accepted_title": "Barátkérelem elfogadva!",
"friend_request_accepted_description": "{{displayName}} elfogadta a barátkérelmed",
"badge_received_title": "Kaptál egy új kitűzőt!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "A véleményed a(z) {{gameTitle}} játékhoz felpont-ot kapott!",
"review_upvote_description": "A véleményed {{count}} új felpontot kapott",
"marked_all_as_read": "Összes értesítés olvasottnak jelölve",
"failed_to_mark_as_read": "Az értesítések olvasottnak jelölése nem sikerült",
"cleared_all": "Összes értesítés eltüntetve",
"failed_to_clear": "Az értesítések eltüntetése nem sikerült",
"failed_to_load": "Az értesítések betöltése nem sikerült",
"failed_to_dismiss": "Értesítés eltüntetése nem sikerült",
"friend_request_accepted": "Barátfelkérés elfogadva",
"friend_request_refused": "Barátfelkérés elutasítva"
} }
} }

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Jogo não possui executável selecionado", "game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login", "sign_in": "Login",
"friends": "Amigos", "friends": "Amigos",
"notifications": "Notificações",
"need_help": "Precisa de ajuda?", "need_help": "Precisa de ajuda?",
"favorites": "Favoritos", "favorites": "Favoritos",
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora", "playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
@@ -163,6 +164,7 @@
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download", "download_now": "Iniciar download",
"loading": "Carregando...",
"no_shop_details": "Não foi possível obter os detalhes da loja.", "no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download", "download_options": "Opções de download",
"download_path": "Diretório de download", "download_path": "Diretório de download",
@@ -368,6 +370,7 @@
"show_translation": "Mostrar tradução", "show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})", "show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original", "hide_original": "Ocultar original",
"vote_failed": "Falha ao registrar seu voto. Por favor, tente novamente.",
"rating_count": "Avaliação", "rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado", "review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar", "show": "Mostrar",
@@ -390,6 +393,10 @@
"completed": "Concluído", "completed": "Concluído",
"removed": "Cancelado", "removed": "Cancelado",
"cancel": "Cancelar", "cancel": "Cancelar",
"cancel_download": "Cancelar download?",
"cancel_download_description": "Tem certeza de que deseja cancelar este download? Todos os arquivos baixados serão excluídos.",
"keep_downloading": "Não, continuar baixando",
"yes_cancel": "Sim, cancelar",
"filter": "Filtrar jogos baixados", "filter": "Filtrar jogos baixados",
"remove": "Remover", "remove": "Remover",
"downloading_metadata": "Baixando metadados…", "downloading_metadata": "Baixando metadados…",
@@ -463,6 +470,7 @@
"download_sources_synced_successfully": "Fontes de download sincronizadas", "download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida", "removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas", "removed_download_sources": "Fontes removidas",
"removed_all_download_sources": "Todas as fontes de download removidas",
"cancel_button_confirmation_delete_all_sources": "Não", "cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo", "confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo",
"title_confirmation_delete_all_sources": "Remover todas as fontes de download", "title_confirmation_delete_all_sources": "Remover todas as fontes de download",
@@ -488,6 +496,7 @@
"blocked_users": "Usuários bloqueados", "blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado", "user_unblocked": "Usuário desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada", "enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"hydra_cloud": "Hydra Cloud",
"launch_minimized": "Iniciar o Hydra minimizado", "launch_minimized": "Iniciar o Hydra minimizado",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado", "disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download", "seed_after_download_complete": "Semear após a conclusão do download",
@@ -550,6 +559,7 @@
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo", "show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download", "extract_files_by_default": "Extrair arquivos automaticamente após o download",
"enable_steam_achievements": "Habilitar busca por conquistas da Steam", "enable_steam_achievements": "Habilitar busca por conquistas da Steam",
"enable_new_download_options_badges": "Mostrar badges de novas opções de download",
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas", "enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
"top-left": "Superior esquerdo", "top-left": "Superior esquerdo",
"top-center": "Superior central", "top-center": "Superior central",
@@ -567,6 +577,9 @@
"test_notification": "Testar notificação", "test_notification": "Testar notificação",
"achievement_sound_volume": "Volume do som de conquista", "achievement_sound_volume": "Volume do som de conquista",
"select_achievement_sound": "Selecionar som de conquista", "select_achievement_sound": "Selecionar som de conquista",
"change_achievement_sound": "Alterar som de conquista",
"remove_achievement_sound": "Remover som de conquista",
"preview_sound": "Reproduzir som",
"select": "Selecionar", "select": "Selecionar",
"preview": "Reproduzir", "preview": "Reproduzir",
"remove": "Remover", "remove": "Remover",
@@ -574,7 +587,10 @@
"notification_preview": "Prévia da Notificação de Conquistas", "notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo" "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo",
"downloads": "Downloads",
"use_native_http_downloader": "Usar downloader HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "Não é possível alterar esta configuração enquanto um download estiver em andamento"
}, },
"notifications": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@@ -680,6 +696,7 @@
"blocked_users": "Usuários bloqueados", "blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados", "no_friends_added": "Você ainda não possui amigos adicionados",
"no_friends_yet": "Você ainda não adicionou nenhum amigo",
"view_all": "Ver todos", "view_all": "Ver todos",
"load_more": "Carregar mais", "load_more": "Carregar mais",
"loading": "Carregando", "loading": "Carregando",
@@ -707,8 +724,15 @@
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:", "your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo", "copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"upload_banner": "Carregar banner", "upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…", "uploading_banner": "Carregando banner…",
"change_banner": "Alterar banner",
"replace_banner": "Substituir banner",
"remove_banner": "Remover banner",
"remove_banner_modal_title": "Remover banner?",
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
"remove": "Remover",
"background_image_updated": "Imagem de fundo salva", "background_image_updated": "Imagem de fundo salva",
"stats": "Estatísticas", "stats": "Estatísticas",
"achievements": "conquistas", "achievements": "conquistas",
@@ -736,8 +760,6 @@
"user_reviews": "Avaliações", "user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...", "loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Meu Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Ainda não há avaliações", "no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação" "delete_review": "Excluir avaliação"
}, },

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Файл запуска игры не выбран", "game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти", "sign_in": "Войти",
"friends": "Друзья", "friends": "Друзья",
"notifications": "Уведомления",
"need_help": "Нужна помощь?", "need_help": "Нужна помощь?",
"favorites": "Избранное", "favorites": "Избранное",
"playable_button_title": "Показать только установленные игры.", "playable_button_title": "Показать только установленные игры.",
@@ -115,6 +116,7 @@
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}", "downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…", "calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)", "checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)",
"extracting": "Распаковка {{title}}… ({{percentage}} завершено)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Установка завершена", "installation_complete": "Установка завершена",
"installation_complete_message": "Библиотеки успешно установлены" "installation_complete_message": "Библиотеки успешно установлены"
@@ -173,6 +175,7 @@
"repacks_modal_description": "Выберите репак для загрузки", "repacks_modal_description": "Выберите репак для загрузки",
"select_folder_hint": "Чтобы изменить папку загрузок по умолчанию, откройте <0>Настройки</0>", "select_folder_hint": "Чтобы изменить папку загрузок по умолчанию, откройте <0>Настройки</0>",
"download_now": "Загрузить сейчас", "download_now": "Загрузить сейчас",
"loading": "Загрузка...",
"no_shop_details": "Не удалось получить описание", "no_shop_details": "Не удалось получить описание",
"download_options": "Источники", "download_options": "Источники",
"download_path": "Путь для загрузок", "download_path": "Путь для загрузок",
@@ -208,6 +211,7 @@
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra", "danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
"download_in_progress": "Идёт загрузка", "download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена", "download_paused": "Загрузка приостановлена",
"extracting": "Распаковка",
"last_downloaded_option": "Последний вариант загрузки", "last_downloaded_option": "Последний вариант загрузки",
"new_download_option": "Новый", "new_download_option": "Новый",
"create_steam_shortcut": "Создать ярлык Steam", "create_steam_shortcut": "Создать ярлык Steam",
@@ -400,6 +404,10 @@
"completed": "Завершено", "completed": "Завершено",
"removed": "Не скачано", "removed": "Не скачано",
"cancel": "Отмена", "cancel": "Отмена",
"cancel_download": "Отменить загрузку?",
"cancel_download_description": "Вы уверены, что хотите отменить эту загрузку? Все загруженные файлы будут удалены.",
"keep_downloading": "Нет, продолжить загрузку",
"yes_cancel": "Да, отменить",
"filter": "Поиск загруженных игр", "filter": "Поиск загруженных игр",
"remove": "Удалить", "remove": "Удалить",
"downloading_metadata": "Загрузка метаданных…", "downloading_metadata": "Загрузка метаданных…",
@@ -420,7 +428,13 @@
"resume_seeding": "Продолжить раздачу", "resume_seeding": "Продолжить раздачу",
"options": "Управлять", "options": "Управлять",
"extract": "Распаковать файлы", "extract": "Распаковать файлы",
"extracting": "Распаковка файлов…" "extracting": "Распаковка файлов…",
"delete_archive_title": "Хотите удалить {{fileName}}?",
"delete_archive_description": "Файл был успешно распакован и больше не нужен.",
"yes": "Да",
"no": "Нет",
"network": "СЕТЬ",
"peak": "ПИК"
}, },
"settings": { "settings": {
"downloads_path": "Путь загрузок", "downloads_path": "Путь загрузок",
@@ -556,6 +570,7 @@
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду", "show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки", "extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
"enable_steam_achievements": "Включить поиск достижений Steam", "enable_steam_achievements": "Включить поиск достижений Steam",
"enable_new_download_options_badges": "Показывать значки новых вариантов загрузки",
"achievement_custom_notification_position": "Позиция уведомлений достижений", "achievement_custom_notification_position": "Позиция уведомлений достижений",
"top-left": "Верхний левый угол", "top-left": "Верхний левый угол",
"top-center": "Верхний центр", "top-center": "Верхний центр",
@@ -573,6 +588,9 @@
"test_notification": "Тестовое уведомление", "test_notification": "Тестовое уведомление",
"achievement_sound_volume": "Громкость звука достижения", "achievement_sound_volume": "Громкость звука достижения",
"select_achievement_sound": "Выбрать звук достижения", "select_achievement_sound": "Выбрать звук достижения",
"change_achievement_sound": "Изменить звук достижения",
"remove_achievement_sound": "Удалить звук достижения",
"preview_sound": "Предпросмотр звука",
"select": "Выбрать", "select": "Выбрать",
"preview": "Предпросмотр", "preview": "Предпросмотр",
"remove": "Удалить", "remove": "Удалить",
@@ -580,7 +598,10 @@
"notification_preview": "Предварительный просмотр уведомления о достижении", "notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры" "hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры",
"downloads": "Загрузки",
"use_native_http_downloader": "Использовать встроенный HTTP-загрузчик (экспериментально)",
"cannot_change_downloader_while_downloading": "Нельзя изменить эту настройку во время загрузки"
}, },
"notifications": { "notifications": {
"download_complete": "Загрузка завершена", "download_complete": "Загрузка завершена",
@@ -675,6 +696,7 @@
"blocked_users": "Заблокированные пользователи", "blocked_users": "Заблокированные пользователи",
"unblock": "Разблокировать", "unblock": "Разблокировать",
"no_friends_added": "Вы ещё не добавили ни одного друга", "no_friends_added": "Вы ещё не добавили ни одного друга",
"no_friends_yet": "Вы ещё не добавили ни одного друга",
"view_all": "Показать все", "view_all": "Показать все",
"load_more": "Загрузить еще", "load_more": "Загрузить еще",
"loading": "Загрузка", "loading": "Загрузка",
@@ -702,8 +724,15 @@
"profile_reported": "Жалоба на профиль отправлена", "profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:", "your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга", "copy_friend_code": "Копировать код друга",
"copied": "Скопировано!",
"upload_banner": "Загрузить баннер", "upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...", "uploading_banner": "Загрузка баннера...",
"change_banner": "Изменить баннер",
"replace_banner": "Заменить баннер",
"remove_banner": "Удалить баннер",
"remove_banner_modal_title": "Удалить баннер?",
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
"remove": "Удалить",
"background_image_updated": "Фоновое изображение обновлено", "background_image_updated": "Фоновое изображение обновлено",
"stats": "Статистика", "stats": "Статистика",
"achievements": "Достижения", "achievements": "Достижения",
@@ -724,8 +753,6 @@
"user_reviews": "Отзывы", "user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...", "loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Просмотреть мой Wrapped 2025",
"view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}",
"no_reviews": "Пока нет отзывов", "no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв" "delete_review": "Удалить отзыв"
}, },

View File

@@ -0,0 +1,59 @@
import path from "node:path";
import fs from "node:fs";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const getGameInstallerActionType = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
): Promise<"install" | "open-folder"> => {
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download?.folderName) return "open-folder";
const gamePath = path.join(
download.downloadPath ?? (await getDownloadsPath()),
download.folderName
);
if (!fs.existsSync(gamePath)) {
await downloadsSublevel.del(downloadKey);
return "open-folder";
}
// macOS always opens folder
if (process.platform === "darwin") {
return "open-folder";
}
// If path is a file, it will show in folder (open-folder behavior)
if (fs.lstatSync(gamePath).isFile()) {
return "open-folder";
}
// Check for setup.exe
const setupPath = path.join(gamePath, "setup.exe");
if (fs.existsSync(setupPath)) {
return "install";
}
// Check if there's exactly one .exe file
const gamePathFileNames = fs.readdirSync(gamePath);
const gamePathExecutableFiles = gamePathFileNames.filter(
(fileName: string) => path.extname(fileName).toLowerCase() === ".exe"
);
if (gamePathExecutableFiles.length === 1) {
return "install";
}
// Otherwise, opens folder
return "open-folder";
};
registerEvent("getGameInstallerActionType", getGameInstallerActionType);

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { downloadsSublevel } from "./level/sublevels/downloads";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { levelKeys, db } from "./level"; import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types"; import type { Download, UserPreferences } from "@types";
import { import {
SystemPath, SystemPath,
CommonRedistManager, CommonRedistManager,
@@ -18,6 +18,7 @@ import {
DeckyPlugin, DeckyPlugin,
DownloadSourcesChecker, DownloadSourcesChecker,
WSClient, WSClient,
logger,
} from "@main/services"; } from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources"; import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -71,18 +72,47 @@ export const loadState = async () => {
return orderBy(games, "timestamp", "desc"); return orderBy(games, "timestamp", "desc");
}); });
downloads.forEach((download) => { let interruptedDownload: Download | null = null;
for (const download of downloads) {
const downloadKey = levelKeys.game(download.shop, download.objectId);
// Reset extracting state
if (download.extracting) { if (download.extracting) {
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), { await downloadsSublevel.put(downloadKey, {
...download, ...download,
extracting: false, extracting: false,
}); });
} }
});
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) =>
game.shouldSeed && game.shouldSeed &&
game.downloader === Downloader.Torrent && game.downloader === Downloader.Torrent &&
@@ -90,7 +120,23 @@ export const loadState = async () => {
game.uri !== null game.uri !== null
); );
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); // For torrents or if JS downloader is disabled, use Python RPC
const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
// Default to true - native HTTP downloader is enabled by default
const useJsDownloader =
(userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
if (useJsDownloader && downloadToResume) {
// Start Python RPC for seeding only, then resume HTTP download with JS
await DownloadManager.startRPC(undefined, downloadsToSeed);
await DownloadManager.startDownload(downloadToResume).catch((err) => {
// If resume fails, just log it - user can manually retry
logger.error("Failed to auto-resume download:", err);
});
} else {
// Use Python RPC for everything (torrent or fallback)
await DownloadManager.startRPC(downloadToResume, downloadsToSeed);
}
startMainLoop(); startMainLoop();

View File

@@ -4,11 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types"; import type { Download, DownloadProgress, UserPreferences } from "@types";
import { import {
GofileApi, GofileApi,
QiwiApi,
DatanodesApi, DatanodesApi,
MediafireApi, MediafireApi,
PixelDrainApi, PixelDrainApi,
VikingFileApi, VikingFileApi,
RootzApi,
} from "../hosters"; } from "../hosters";
import { PythonRPC } from "../python-rpc"; import { PythonRPC } from "../python-rpc";
import { import {
@@ -18,7 +18,7 @@ import {
} from "./types"; } from "./types";
import { calculateETA, getDirSize } from "./helpers"; import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import path from "path"; import path from "node:path";
import { logger } from "../logger"; import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
@@ -26,9 +26,13 @@ import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager"; import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid"; import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
import { JsHttpDownloader } from "./js-http-downloader";
export class DownloadManager { export class DownloadManager {
private static downloadingGameId: string | null = null; private static downloadingGameId: string | null = null;
private static jsDownloader: JsHttpDownloader | null = null;
private static usingJsDownloader = false;
private static isPreparingDownload = false;
private static extractFilename( private static extractFilename(
url: string, url: string,
@@ -52,7 +56,7 @@ export class DownloadManager {
const urlObj = new URL(url); const urlObj = new URL(url);
const pathname = urlObj.pathname; const pathname = urlObj.pathname;
const pathParts = pathname.split("/"); const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1]; const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) { if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename); return decodeURIComponent(filename);
@@ -68,6 +72,34 @@ export class DownloadManager {
return filename.replaceAll(/[<>:"/\\|?*]/g, "_"); 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( private static createDownloadPayload(
directUrl: string, directUrl: string,
originalUrl: string, originalUrl: string,
@@ -99,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( public static async startRPC(
download?: Download, download?: Download,
downloadsToSeed?: Download[] downloadsToSeed?: Download[]
@@ -123,7 +168,87 @@ export class DownloadManager {
} }
} }
private static async getDownloadStatus() { private static async getDownloadStatusFromJs(): Promise<DownloadProgress | null> {
if (!this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
// Return a "preparing" status while fetching download options
if (this.isPreparingDownload) {
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed: 0,
timeRemaining: -1,
isDownloadingMetadata: true, // Use this to indicate "preparing"
isCheckingFiles: false,
progress: 0,
gameId: downloadId,
download,
};
} catch {
return null;
}
}
if (!this.jsDownloader) return null;
const status = this.jsDownloader.getDownloadStatus();
if (!status) return null;
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } =
status;
// Only update fileSize in database if we actually know it (> 0)
// Otherwise keep the existing value to avoid showing "0 B"
const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize;
const updatedDownload = {
...download,
bytesDownloaded,
fileSize: effectiveFileSize,
progress,
folderName,
status:
status.status === "complete"
? ("complete" as const)
: ("active" as const),
};
if (status.status === "active" || status.status === "complete") {
await downloadsSublevel.put(downloadId, updatedDownload);
}
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed,
timeRemaining: calculateETA(
effectiveFileSize ?? 0,
bytesDownloaded,
downloadSpeed
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: downloadId,
download: updatedDownload,
};
} catch (err) {
logger.error("[DownloadManager] Error getting JS download status:", err);
return null;
}
}
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>( const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status" "/status"
); );
@@ -151,28 +276,14 @@ export class DownloadManager {
if (!isDownloadingMetadata && !isCheckingFiles) { if (!isDownloadingMetadata && !isCheckingFiles) {
if (!download) return null; if (!download) return null;
const updatedDownload = { await downloadsSublevel.put(downloadId, {
...download, ...download,
bytesDownloaded, bytesDownloaded,
fileSize, fileSize,
progress, progress,
folderName, folderName,
status: "active" as const, status: "active",
}; });
await downloadsSublevel.put(downloadId, updatedDownload);
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId: downloadId,
download: updatedDownload,
} as DownloadProgress;
} }
return { return {
@@ -186,105 +297,141 @@ export class DownloadManager {
gameId: downloadId, gameId: downloadId,
download, download,
} as DownloadProgress; } as DownloadProgress;
} catch (err) { } catch {
return null; return null;
} }
} }
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
if (this.usingJsDownloader) {
return this.getDownloadStatusFromJs();
}
return this.getDownloadStatusFromRpc();
}
public static async watchDownloads() { public static async watchDownloads() {
const status = await this.getDownloadStatus(); const status = await this.getDownloadStatus();
if (!status) return;
if (status) { const { gameId, progress } = status;
const { gameId, progress } = status; const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
const [download, game] = await Promise.all([ if (!download || !game) return;
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
if (!download || !game) return; this.sendProgressUpdate(progress, status, game);
const userPreferences = await db.get<string, UserPreferences | null>( if (progress === 1) {
levelKeys.userPreferences, await this.handleDownloadCompletion(download, game, gameId);
{ valueEncoding: "json" } }
}
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) { const [nextItemOnQueue] = downloads;
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
);
}
const shouldExtract = download.automaticallyExtract; if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
if (progress === 1 && download) { } else {
publishDownloadCompleteNotification(game); this.downloadingGameId = null;
this.usingJsDownloader = false;
if ( this.jsDownloader = null;
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;
}
}
} }
} }
@@ -324,12 +471,17 @@ export class DownloadManager {
} }
static async pauseDownload(downloadKey = this.downloadingGameId) { static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc if (this.usingJsDownloader && this.jsDownloader) {
.post("/action", { logger.log("[DownloadManager] Pausing JS download");
action: "pause", this.jsDownloader.pauseDownload();
game_id: downloadKey, } else {
} as PauseDownloadPayload) await PythonRPC.rpc
.catch(() => {}); .post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
}
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -342,14 +494,23 @@ export class DownloadManager {
} }
static async cancelDownload(downloadKey = this.downloadingGameId) { static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc if (this.usingJsDownloader && this.jsDownloader) {
.post("/action", { action: "cancel", game_id: downloadKey }) logger.log("[DownloadManager] Cancelling JS download");
.catch((err) => logger.error("Failed to cancel game download", err)); 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) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null); WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null; this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingJsDownloader = false;
} }
} }
@@ -369,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) { private static async getDownloadPayload(download: Download) {
const downloadId = levelKeys.game(download.shop, download.objectId); const downloadId = levelKeys.game(download.shop, download.objectId);
@@ -400,15 +796,6 @@ export class DownloadManager {
save_path: download.downloadPath, save_path: download.downloadPath,
}; };
} }
case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
case Downloader.Datanodes: { case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return { return {
@@ -518,31 +905,71 @@ export class DownloadManager {
logger.log( logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}` `[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
); );
try { const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); return this.createDownloadPayload(
logger.log(`[DownloadManager] VikingFile direct URL obtained`); downloadUrl,
return { download.uri,
action: "start", downloadId,
game_id: downloadId, download.downloadPath
url: downloadUrl, );
save_path: download.downloadPath,
header:
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
};
} catch (error) {
logger.error(
`[DownloadManager] Error processing VikingFile download:`,
error
);
throw error;
}
} }
case Downloader.Rootz: {
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
default:
return undefined;
} }
} }
static async startDownload(download: Download) { static async startDownload(download: Download) {
const payload = await this.getDownloadPayload(download); const useJsDownloader = await this.shouldUseJsDownloader();
await PythonRPC.rpc.post("/action", payload); const isHttp = this.isHttpDownloader(download.downloader);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId); const downloadId = levelKeys.game(download.shop, download.objectId);
if (useJsDownloader && isHttp) {
logger.log("[DownloadManager] Using JS HTTP downloader");
// Set preparing state immediately so UI knows download is starting
this.downloadingGameId = downloadId;
this.isPreparingDownload = true;
this.usingJsDownloader = true;
try {
const options = await this.getJsDownloadOptions(download);
if (!options) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw new Error("Failed to get download options for JS downloader");
}
this.jsDownloader = new JsHttpDownloader();
this.isPreparingDownload = false;
this.jsDownloader.startDownload(options).catch((err) => {
logger.error("[DownloadManager] JS download error:", err);
this.usingJsDownloader = false;
this.jsDownloader = null;
});
} catch (err) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw err;
}
} else {
logger.log("[DownloadManager] Using Python RPC downloader");
const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = downloadId;
this.usingJsDownloader = false;
}
} }
} }

View File

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

View File

@@ -0,0 +1,380 @@
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { logger } from "../logger";
export interface JsHttpDownloaderStatus {
folderName: string;
fileSize: number;
progress: number;
downloadSpeed: number;
numPeers: number;
numSeeds: number;
status: "active" | "paused" | "complete" | "error";
bytesDownloaded: number;
}
export interface JsHttpDownloaderOptions {
url: string;
savePath: string;
filename?: string;
headers?: Record<string, string>;
}
export class JsHttpDownloader {
private abortController: AbortController | null = null;
private writeStream: fs.WriteStream | null = null;
private currentOptions: JsHttpDownloaderOptions | null = null;
private bytesDownloaded = 0;
private fileSize = 0;
private downloadSpeed = 0;
private status: "active" | "paused" | "complete" | "error" = "paused";
private folderName = "";
private lastSpeedUpdate = Date.now();
private bytesAtLastSpeedUpdate = 0;
private isDownloading = false;
async startDownload(options: JsHttpDownloaderOptions): Promise<void> {
if (this.isDownloading) {
logger.log(
"[JsHttpDownloader] Download already in progress, resuming..."
);
return this.resumeDownload();
}
this.currentOptions = options;
this.abortController = new AbortController();
this.status = "active";
this.isDownloading = true;
const { url, savePath, filename, headers = {} } = options;
const { filePath, startByte, usedFallback } = this.prepareDownloadPath(
savePath,
filename,
url
);
const requestHeaders = this.buildRequestHeaders(headers, startByte);
try {
await this.executeDownload(
url,
requestHeaders,
filePath,
startByte,
savePath,
usedFallback
);
} catch (err) {
this.handleDownloadError(err as Error);
} finally {
this.isDownloading = false;
this.cleanup();
}
}
private prepareDownloadPath(
savePath: string,
filename: string | undefined,
url: string
): { filePath: string; startByte: number; usedFallback: boolean } {
const extractedFilename = filename || this.extractFilename(url);
const usedFallback = !extractedFilename;
const resolvedFilename = extractedFilename || "download";
this.folderName = resolvedFilename;
const filePath = path.join(savePath, resolvedFilename);
if (!fs.existsSync(savePath)) {
fs.mkdirSync(savePath, { recursive: true });
}
let startByte = 0;
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
startByte = stats.size;
this.bytesDownloaded = startByte;
logger.log(`[JsHttpDownloader] Resuming download from byte ${startByte}`);
}
this.resetSpeedTracking();
return { filePath, startByte, usedFallback };
}
private buildRequestHeaders(
headers: Record<string, string>,
startByte: number
): Record<string, string> {
const requestHeaders: Record<string, string> = { ...headers };
if (startByte > 0) {
requestHeaders["Range"] = `bytes=${startByte}-`;
}
return requestHeaders;
}
private resetSpeedTracking(): void {
this.lastSpeedUpdate = Date.now();
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
this.downloadSpeed = 0;
}
private parseFileSize(response: Response, startByte: number): void {
const contentRange = response.headers.get("content-range");
if (contentRange) {
const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange);
if (match) {
this.fileSize = Number.parseInt(match[1], 10);
}
return;
}
const contentLength = response.headers.get("content-length");
if (contentLength) {
this.fileSize = startByte + Number.parseInt(contentLength, 10);
}
}
private async executeDownload(
url: string,
requestHeaders: Record<string, string>,
filePath: string,
startByte: number,
savePath: string,
usedFallback: boolean
): Promise<void> {
const response = await fetch(url, {
headers: requestHeaders,
signal: this.abortController?.signal,
});
// Handle 416 Range Not Satisfiable - existing file is larger than server file
// This happens when downloading same game from different source
if (response.status === 416 && startByte > 0) {
logger.log(
"[JsHttpDownloader] Range not satisfiable, deleting existing file and restarting"
);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
this.bytesDownloaded = 0;
this.resetSpeedTracking();
// Retry without Range header
const headersWithoutRange = { ...requestHeaders };
delete headersWithoutRange["Range"];
return this.executeDownload(
url,
headersWithoutRange,
filePath,
0,
savePath,
usedFallback
);
}
if (!response.ok && response.status !== 206) {
throw new Error(`HTTP error! status: ${response.status}`);
}
this.parseFileSize(response, startByte);
// If we used "download" fallback, try to get filename from Content-Disposition
let actualFilePath = filePath;
if (usedFallback && startByte === 0) {
const headerFilename = this.parseContentDisposition(response);
if (headerFilename) {
actualFilePath = path.join(savePath, headerFilename);
this.folderName = headerFilename;
logger.log(
`[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}`
);
}
}
if (!response.body) {
throw new Error("Response body is null");
}
const flags = startByte > 0 ? "a" : "w";
this.writeStream = fs.createWriteStream(actualFilePath, { flags });
const readableStream = this.createReadableStream(response.body.getReader());
await pipeline(readableStream, this.writeStream);
this.status = "complete";
this.downloadSpeed = 0;
logger.log("[JsHttpDownloader] Download complete");
}
private parseContentDisposition(response: Response): string | undefined {
const header = response.headers.get("content-disposition");
if (!header) return undefined;
// Try to extract filename from Content-Disposition header
// Formats: attachment; filename="file.zip" or attachment; filename=file.zip
const filenameMatch = /filename\*?=['"]?(?:UTF-8'')?([^"';\n]+)['"]?/i.exec(
header
);
if (filenameMatch?.[1]) {
try {
return decodeURIComponent(filenameMatch[1].trim());
} catch {
return filenameMatch[1].trim();
}
}
return undefined;
}
private createReadableStream(
reader: ReadableStreamDefaultReader<Uint8Array>
): Readable {
const onChunk = (length: number) => {
this.bytesDownloaded += length;
this.updateSpeed();
};
return new Readable({
read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
this.push(null);
return;
}
onChunk(value.length);
this.push(Buffer.from(value));
})
.catch((err: Error) => {
if (err.name === "AbortError") {
this.push(null);
} else {
this.destroy(err);
}
});
},
});
}
private handleDownloadError(err: Error): void {
// Handle abort/cancellation errors - these are expected when user pauses/cancels
if (
err.name === "AbortError" ||
(err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE"
) {
logger.log("[JsHttpDownloader] Download aborted");
this.status = "paused";
} else {
logger.error("[JsHttpDownloader] Download error:", err);
this.status = "error";
throw err;
}
}
private async resumeDownload(): Promise<void> {
if (!this.currentOptions) {
throw new Error("No download options available for resume");
}
this.isDownloading = false;
await this.startDownload(this.currentOptions);
}
pauseDownload(): void {
if (this.abortController) {
logger.log("[JsHttpDownloader] Pausing download");
this.abortController.abort();
this.status = "paused";
this.downloadSpeed = 0;
}
}
cancelDownload(deleteFile = true): void {
if (this.abortController) {
logger.log("[JsHttpDownloader] Cancelling download");
this.abortController.abort();
}
this.cleanup();
if (deleteFile && this.currentOptions && this.status !== "complete") {
const filePath = path.join(this.currentOptions.savePath, this.folderName);
if (fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
logger.log("[JsHttpDownloader] Deleted partial file");
} catch (err) {
logger.error(
"[JsHttpDownloader] Failed to delete partial file:",
err
);
}
}
}
this.reset();
}
getDownloadStatus(): JsHttpDownloaderStatus | null {
if (!this.currentOptions && this.status !== "active") {
return null;
}
return {
folderName: this.folderName,
fileSize: this.fileSize,
progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0,
downloadSpeed: this.downloadSpeed,
numPeers: 0,
numSeeds: 0,
status: this.status,
bytesDownloaded: this.bytesDownloaded,
};
}
private updateSpeed(): void {
const now = Date.now();
const elapsed = (now - this.lastSpeedUpdate) / 1000;
if (elapsed >= 1) {
const bytesDelta = this.bytesDownloaded - this.bytesAtLastSpeedUpdate;
this.downloadSpeed = bytesDelta / elapsed;
this.lastSpeedUpdate = now;
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
}
}
private extractFilename(url: string): string | undefined {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch {
// Invalid URL
}
return undefined;
}
private cleanup(): void {
if (this.writeStream) {
this.writeStream.close();
this.writeStream = null;
}
this.abortController = null;
}
private reset(): void {
this.currentOptions = null;
this.bytesDownloaded = 0;
this.fileSize = 0;
this.downloadSpeed = 0;
this.status = "paused";
this.folderName = "";
this.isDownloading = false;
}
}

View File

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

View File

@@ -1,15 +0,0 @@
import { requestWebPage } from "@main/helpers";
export class QiwiApi {
public static async getDownloadUrl(url: string) {
const document = await requestWebPage(url);
const fileName = document.querySelector("h1")?.textContent;
const slug = url.split("/").pop();
const extension = fileName?.split(".").pop();
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
return downloadUrl;
}
}

View File

@@ -0,0 +1,58 @@
import axios, { AxiosError } from "axios";
import { logger } from "../logger";
interface RootzApiResponse {
success: boolean;
data?: {
url: string;
fileName: string;
size: number;
mimeType: string;
expiresIn: number;
expiresAt: string | null;
downloads: number;
canDelete: boolean;
fileId: string;
isMirrored: boolean;
sourceService: string | null;
adsEnabled: boolean;
};
error?: string;
}
export class RootzApi {
public static async getDownloadUrl(uri: string): Promise<string> {
try {
const url = new URL(uri);
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length < 2 || pathSegments[0] !== "d") {
throw new Error("Invalid rootz URL format");
}
const id = pathSegments[1];
const apiUrl = `https://www.rootz.so/api/files/download-by-short/${id}`;
const response = await axios.get<RootzApiResponse>(apiUrl);
if (response.data.success && response.data.data?.url) {
return response.data.data.url;
}
throw new Error("Failed to get download URL from rootz API");
} catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<RootzApiResponse>;
if (axiosError.response?.status === 404) {
const errorMessage =
axiosError.response.data?.error || "File not found";
logger.error(`[Rootz] ${errorMessage}`);
throw new Error(errorMessage);
}
}
logger.error("[Rootz] Error fetching download URL:", error);
throw error;
}
}
}

View File

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

View File

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

View File

@@ -224,21 +224,6 @@ export function Header() {
setActiveIndex(-1); 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(() => { useEffect(() => {
if (!isDropdownVisible) return; if (!isDropdownVisible) return;

View File

@@ -7,7 +7,6 @@ export const DOWNLOADER_NAME = {
[Downloader.Torrent]: "Torrent", [Downloader.Torrent]: "Torrent",
[Downloader.Gofile]: "Gofile", [Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain", [Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes", [Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire", [Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier", [Downloader.Buzzheavier]: "Buzzheavier",
@@ -15,6 +14,7 @@ export const DOWNLOADER_NAME = {
[Downloader.TorBox]: "TorBox", [Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus", [Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile", [Downloader.VikingFile]: "VikingFile",
[Downloader.Rootz]: "Rootz",
}; };
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

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

View File

@@ -31,11 +31,16 @@ export const downloadSlice = createSlice({
reducers: { reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => { setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload; state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Ensure payload exists and has a valid gameId before accessing
const payload = action.payload;
if (!state.gameId && payload?.gameId) {
state.gameId = payload.gameId;
}
// Track peak speed and speed history atomically when packet arrives // Track peak speed and speed history atomically when packet arrives
if (action.payload?.gameId && action.payload.downloadSpeed != null) { if (payload?.gameId && payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload; const { gameId, downloadSpeed } = payload;
// Update peak speed if this is higher // Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0; const currentPeak = state.peakSpeeds[gameId] || 0;

View File

@@ -427,7 +427,7 @@
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
width: 100%; width: fit-content;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
&:focus, &:focus,
@@ -509,6 +509,15 @@
&__simple-menu-btn { &__simple-menu-btn {
padding: calc(globals.$spacing-unit); padding: calc(globals.$spacing-unit);
min-height: unset; min-height: unset;
border-radius: 8px;
}
&__simple-action-btn {
padding: calc(globals.$spacing-unit);
min-height: unset;
gap: calc(globals.$spacing-unit);
min-width: 120px;
border-radius: 8px;
} }
&__progress-wrapper { &__progress-wrapper {

View File

@@ -1,6 +1,6 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components"; import { Badge, Button, ConfirmationModal } from "@renderer/components";
import { import {
formatDownloadProgress, formatDownloadProgress,
buildGameDetailsPath, buildGameDetailsPath,
@@ -32,12 +32,12 @@ import {
FileDirectoryIcon, FileDirectoryIcon,
LinkIcon, LinkIcon,
PlayIcon, PlayIcon,
ThreeBarsIcon,
TrashIcon, TrashIcon,
UnlinkIcon, UnlinkIcon,
XCircleIcon, XCircleIcon,
GraphIcon, GraphIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js"; import { average } from "color.js";
interface AnimatedPercentageProps { interface AnimatedPercentageProps {
@@ -219,7 +219,7 @@ interface HeroDownloadViewProps {
calculateETA: () => string; calculateETA: () => string;
pauseDownload: (shop: GameShop, objectId: string) => void; pauseDownload: (shop: GameShop, objectId: string) => void;
resumeDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void;
cancelDownload: (shop: GameShop, objectId: string) => void; onCancelClick: (shop: GameShop, objectId: string) => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -238,7 +238,7 @@ function HeroDownloadView({
calculateETA, calculateETA,
pauseDownload, pauseDownload,
resumeDownload, resumeDownload,
cancelDownload, onCancelClick,
t, t,
}: Readonly<HeroDownloadViewProps>) { }: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -353,7 +353,7 @@ function HeroDownloadView({
)} )}
<button <button
type="button" type="button"
onClick={() => cancelDownload(game.shop, game.objectId)} onClick={() => onCancelClick(game.shop, game.objectId)}
className="download-group__glass-btn" className="download-group__glass-btn"
> >
<XCircleIcon size={14} /> <XCircleIcon size={14} />
@@ -452,6 +452,7 @@ export function DownloadGroup({
seedingStatus, seedingStatus,
}: Readonly<DownloadGroupProps>) { }: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
const navigate = useNavigate(); const navigate = useNavigate();
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
@@ -523,6 +524,16 @@ export function DownloadGroup({
const [optimisticallyResumed, setOptimisticallyResumed] = useState< const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
null
);
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
string | null
>(null);
const [gameActionTypes, setGameActionTypes] = useState<
Record<string, "install" | "open-folder">
>({});
const extractDominantColor = useCallback( const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => { async (imageUrl: string, gameId: string) => {
@@ -613,11 +624,18 @@ export function DownloadGroup({
const download = game.download!; const download = game.download!;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
if (download.fileSize != null) return formatBytes(download.fileSize); // Check lastPacket first for most up-to-date size during active downloads
if (
if (lastPacket?.download.fileSize && isGameDownloading) isGameDownloading &&
lastPacket?.download.fileSize &&
lastPacket.download.fileSize > 0
)
return formatBytes(lastPacket.download.fileSize); return formatBytes(lastPacket.download.fileSize);
// Then check the stored download size (must be > 0 to be valid)
if (download.fileSize != null && download.fileSize > 0)
return formatBytes(download.fileSize);
return "N/A"; return "N/A";
}; };
@@ -651,6 +669,27 @@ export function DownloadGroup({
[updateLibrary] [updateLibrary]
); );
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
setGameToCancelShop(shop);
setGameToCancelObjectId(objectId);
setCancelModalVisible(true);
}, []);
const handleConfirmCancel = useCallback(async () => {
if (gameToCancelShop && gameToCancelObjectId) {
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
}
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
const handleCancelModalClose = useCallback(() => {
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, []);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download; const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
@@ -659,14 +698,6 @@ export function DownloadGroup({
if (game.download?.progress === 1) { if (game.download?.progress === 1) {
const actions = [ const actions = [
{
label: t("install"),
disabled: deleting,
onClick: () => {
openGameInstaller(game.shop, game.objectId);
},
icon: <DownloadIcon />,
},
{ {
label: t("extract"), label: t("extract"),
disabled: game.download.extracting, disabled: game.download.extracting,
@@ -721,7 +752,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -746,7 +777,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -770,6 +801,37 @@ export function DownloadGroup({
] ]
); );
// Fetch action types for completed games
useEffect(() => {
const fetchActionTypes = async () => {
const completedGames = library.filter(
(game) => game.download?.progress === 1
);
const actionTypesPromises = completedGames.map(async (game) => {
try {
const actionType = await window.electron.getGameInstallerActionType(
game.shop,
game.objectId
);
return { gameId: game.id, actionType };
} catch {
return { gameId: game.id, actionType: "open-folder" as const };
}
});
const results = await Promise.all(actionTypesPromises);
const newActionTypes: Record<string, "install" | "open-folder"> = {};
results.forEach(({ gameId, actionType }) => {
newActionTypes[gameId] = actionType;
});
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
};
fetchActionTypes();
}, [library]);
if (!library.length) return null; if (!library.length) return null;
const isDownloadingGroup = title === t("download_in_progress"); const isDownloadingGroup = title === t("download_in_progress");
@@ -804,136 +866,179 @@ export function DownloadGroup({
const dominantColor = dominantColors[game.id] || "#fff"; const dominantColor = dominantColors[game.id] || "#fff";
return ( return (
<HeroDownloadView <>
game={game} <ConfirmationModal
isGameDownloading={isGameDownloading} visible={cancelModalVisible}
isGameExtracting={isGameExtracting} title={t("cancel_download")}
downloadSpeed={downloadSpeed} descriptionText={t("cancel_download_description")}
finalDownloadSize={finalDownloadSize} confirmButtonLabel={t("yes_cancel")}
peakSpeed={peakSpeed} cancelButtonLabel={t("keep_downloading")}
currentProgress={currentProgress} onConfirm={handleConfirmCancel}
dominantColor={dominantColor} onClose={handleCancelModalClose}
lastPacket={lastPacket} />
speedHistory={gameSpeedHistory} <HeroDownloadView
formatSpeed={formatSpeed} game={game}
calculateETA={calculateETA} isGameDownloading={isGameDownloading}
pauseDownload={pauseDownload} isGameExtracting={isGameExtracting}
resumeDownload={resumeDownload} downloadSpeed={downloadSpeed}
cancelDownload={cancelDownload} finalDownloadSize={finalDownloadSize}
t={t} peakSpeed={peakSpeed}
/> currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
resumeDownload={resumeDownload}
onCancelClick={handleCancelClick}
t={t}
/>
</>
); );
} }
return ( return (
<div <>
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`} <ConfirmationModal
> visible={cancelModalVisible}
<div className="download-group__header"> title={t("cancel_download")}
<div className="download-group__header-title-group"> descriptionText={t("cancel_download_description")}
<h2>{title}</h2> confirmButtonLabel={t("yes_cancel")}
<h3 className="download-group__header-count">{library.length}</h3> 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>
</div>
<ul className="download-group__simple-list"> <ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return ( return (
<li key={game.id} className="download-group__simple-card"> <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">
<button <button
type="button" type="button"
onClick={() => navigate(buildGameDetailsPath(game))} 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> </button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row"> <div className="download-group__simple-info">
<Badge> <button
{DOWNLOADER_NAME[Number(game.download!.downloader)]} type="button"
</Badge> onClick={() => navigate(buildGameDetailsPath(game))}
</div> className="download-group__simple-title-button"
<div className="download-group__simple-meta-row"> >
{extraction?.visibleId === game.id ? ( <h3 className="download-group__simple-title">
<span className="download-group__simple-extracting"> {game.title}
{t("extracting")} ( </h3>
{Math.round(extraction.progress * 100)}%) </button>
</span> <div className="download-group__simple-meta">
) : ( <div className="download-group__simple-meta-row">
<span className="download-group__simple-size"> <Badge>
<DownloadIcon size={14} /> {DOWNLOADER_NAME[Number(game.download!.downloader)]}
{size} </Badge>
</span> </div>
)} <div className="download-group__simple-meta-row">
{game.download?.progress === 1 && seeding && ( {extraction?.visibleId === game.id ? (
<span className="download-group__simple-seeding"> <span className="download-group__simple-extracting">
{t("seeding")} {t("extracting")} (
</span> {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> </div>
</div>
{isQueuedGroup && ( {isQueuedGroup && (
<div className="download-group__simple-progress"> <div className="download-group__simple-progress">
<span className="download-group__simple-progress-text"> <span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)} {formatDownloadProgress(progress)}
</span> </span>
<div className="download-group__progress-bar download-group__progress-bar--small"> <div className="download-group__progress-bar download-group__progress-bar--small">
<div <div
className="download-group__progress-fill" className="download-group__progress-fill"
style={{ style={{
width: `${progress * 100}%`, width: `${progress * 100}%`,
backgroundColor: "#fff", backgroundColor: "#fff",
}} }}
/> />
</div>
</div> </div>
</div> )}
)}
<div className="download-group__simple-actions"> <div className="download-group__simple-actions">
{game.download?.progress === 1 && ( {game.download?.progress === 1 &&
<Button (() => {
theme="primary" const actionType =
onClick={() => openGameInstaller(game.shop, game.objectId)} gameActionTypes[game.id] || "open-folder";
disabled={isGameDeleting(game.id)} const isInstall = actionType === "install";
className="download-group__simple-menu-btn"
> return (
<PlayIcon size={16} /> <Button
</Button> theme="primary"
)} onClick={() =>
{isQueuedGroup && game.download?.progress !== 1 && ( openGameInstaller(game.shop, game.objectId)
<Button }
theme="primary" disabled={isGameDeleting(game.id)}
onClick={() => resumeDownload(game.shop, game.objectId)} className="download-group__simple-action-btn"
className="download-group__simple-menu-btn" >
tooltip={t("resume")} {isInstall ? (
> <>
<DownloadIcon size={16} /> <DownloadIcon size={16} />
</Button> {t("install")}
)} </>
<DropdownMenu align="end" items={getGameActions(game)}> ) : (
<Button <>
theme="outline" <Folder size={16} />
className="download-group__simple-menu-btn" {tGameDetails("open_folder")}
> </>
<ThreeBarsIcon /> )}
</Button> </Button>
</DropdownMenu> );
</div> })()}
</li> {isQueuedGroup && game.download?.progress !== 1 && (
); <Button
})} theme="primary"
</ul> onClick={() => resumeDownload(game.shop, game.objectId)}
</div> className="download-group__simple-menu-btn"
tooltip={t("resume")}
>
<DownloadIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
theme="outline"
className="download-group__simple-menu-btn"
>
<MoreVertical size={16} />
</Button>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</div>
</>
); );
} }

View File

@@ -91,6 +91,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: globals.$spacing-unit; gap: globals.$spacing-unit;
padding-bottom: calc(globals.$spacing-unit * 3);
} }
&__empty { &__empty {
@@ -134,5 +135,6 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit * 3);
} }
} }

View File

@@ -4,6 +4,20 @@
&__box { &__box {
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
position: relative; 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 { &__add-friend-button {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext } from "react"; import { useCallback, useContext, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks"; 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 { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react"; import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { Award } from "lucide-react"; import { Award } from "lucide-react";
import { WrappedFullscreenModal } from "./wrapped-tab";
import "./user-stats-box.scss"; import "./user-stats-box.scss";
export function UserStatsBox() { export function UserStatsBox() {
const [showWrappedModal, setShowWrappedModal] = useState(false);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { userStats, isMe, userProfile } = useContext(userProfileContext); const { userStats, isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
@@ -41,6 +43,18 @@ export function UserStatsBox() {
return ( return (
<div className="user-stats__box"> <div className="user-stats__box">
<ul className="user-stats__list"> <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) && ( {(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item"> <li className="user-stats__list-item">
<h3 className="user-stats__list-title"> <h3 className="user-stats__list-title">
@@ -126,6 +140,14 @@ export function UserStatsBox() {
</li> </li>
)} )}
</ul> </ul>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { UploadIcon } from "@primer/octicons-react"; import { TrashIcon, UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components"; import { MoreVertical } from "lucide-react";
import { useContext, useState } from "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 { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,16 +11,33 @@ import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() { export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] = const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false); 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 { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext); const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } =
useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails(); const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast(); 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 { try {
const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"], properties: ["openFile"],
@@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() {
showSuccessToast(t("background_image_updated")); showSuccessToast(t("background_image_updated"));
await fetchUserDetails(); await fetchUserDetails();
await getUserProfile();
} }
} finally { } finally {
setIsUploadingBackgorundImage(false); 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; if (!isMe || !hasActiveSubscription) return null;
return ( // If no banner exists, show the original upload button
<Button if (!hasBanner) {
theme="outline" return (
className="upload-background-image-button" <div className="upload-background-image-button__wrapper">
onClick={handleChangeCoverClick} <Button
disabled={isUploadingBackgroundImage} 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 /> <button
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")} type="button"
</Button> className="upload-background-image-button__menu-item"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon size={16} />
{t("replace_banner")}
</button>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleRemoveBannerClick}
disabled={isUploadingBackgroundImage}
>
<TrashIcon size={16} />
{t("remove_banner")}
</button>
</div>
);
return (
<>
<div ref={buttonRef} className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
disabled={isUploadingBackgroundImage}
>
{t("change_banner")}
<MoreVertical size={16} />
</Button>
</div>
{createPortal(menuContent, document.body)}
<ConfirmationModal
visible={showRemoveBannerModal}
title={t("remove_banner_modal_title")}
descriptionText={t("remove_banner_confirmation")}
onClose={() => setShowRemoveBannerModal(false)}
onConfirm={handleRemoveBannerConfirm}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
buttonsIsDisabled={isUploadingBackgroundImage}
/>
</>
); );
} }

View File

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

View File

@@ -18,6 +18,13 @@
align-self: flex-start; 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 { &__volume-control {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -37,6 +37,12 @@ export function SettingsGeneral() {
(state) => state.userPreferences.value (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 [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
const [installingCommonRedist, setInstallingCommonRedist] = useState(false); const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
@@ -53,6 +59,7 @@ export function SettingsGeneral() {
achievementSoundVolume: 15, achievementSoundVolume: 15,
language: "", language: "",
customStyles: window.localStorage.getItem("customStyles") || "", customStyles: window.localStorage.getItem("customStyles") || "",
useNativeHttpDownloader: true,
}); });
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]); const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
@@ -131,6 +138,8 @@ export function SettingsGeneral() {
friendStartGameNotificationsEnabled: friendStartGameNotificationsEnabled:
userPreferences.friendStartGameNotificationsEnabled ?? true, userPreferences.friendStartGameNotificationsEnabled ?? true,
language: language ?? "en", language: language ?? "en",
useNativeHttpDownloader:
userPreferences.useNativeHttpDownloader ?? true,
})); }));
} }
}, [userPreferences, defaultDownloadsPath]); }, [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> <h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField <CheckboxField

View File

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

View File

@@ -3,7 +3,6 @@ export enum Downloader {
Torrent, Torrent,
Gofile, Gofile,
PixelDrain, PixelDrain,
Qiwi,
Datanodes, Datanodes,
Mediafire, Mediafire,
TorBox, TorBox,
@@ -11,6 +10,7 @@ export enum Downloader {
Buzzheavier, Buzzheavier,
FuckingFast, FuckingFast,
VikingFile, VikingFile,
Rootz,
} }
export enum DownloadSourceStatus { export enum DownloadSourceStatus {

View File

@@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile]; if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain]; 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://datanodes.to")) return [Downloader.Datanodes];
if (uri.startsWith("https://www.mediafire.com")) if (uri.startsWith("https://www.mediafire.com"))
return [Downloader.Mediafire]; return [Downloader.Mediafire];
@@ -127,6 +126,9 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://vikingfile.com")) { if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile]; return [Downloader.VikingFile];
} }
if (uri.startsWith("https://www.rootz.so")) {
return [Downloader.Rootz];
}
if (realDebridHosts.some((host) => uri.startsWith(host))) if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid]; return [Downloader.RealDebrid];

View File

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

View File

@@ -2203,14 +2203,15 @@
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1": "@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1":
version "4.3.1" version "4.4.5"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.3.1.tgz#f1a0ed6faa52377909440002e1632be9fc901840" resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.5.tgz#35e792b6db00887bdd029df9b41780ca005d064b"
integrity sha512-tWDwrWy37CDVGeaP8AIGZPFL2RoFtmd5Y+nTzLw5qroXNedT2S66EY2d+XzB1zxulCd6nfDXnAQu4auq90aj5Q== integrity sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==
dependencies: dependencies:
"@smithy/node-config-provider" "^4.3.1" "@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.7.0" "@smithy/types" "^4.11.0"
"@smithy/util-config-provider" "^4.2.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" tslib "^2.6.2"
"@smithy/core@^3.15.0", "@smithy/core@^3.16.0": "@smithy/core@^3.15.0", "@smithy/core@^3.16.0":
@@ -2421,6 +2422,16 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" 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": "@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0":
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83" 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" "@smithy/types" "^4.7.0"
tslib "^2.6.2" 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": "@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18" resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18"
@@ -2480,6 +2499,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" 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": "@smithy/signature-v4@^5.3.0":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4" 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" "@smithy/util-stream" "^4.5.1"
tslib "^2.6.2" 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": "@smithy/types@^4.6.0", "@smithy/types@^4.7.0":
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f"
@@ -2601,6 +2635,15 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" 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": "@smithy/util-hex-encoding@^4.2.0":
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b" 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" "@smithy/types" "^4.7.0"
tslib "^2.6.2" 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": "@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1":
version "4.2.1" version "4.2.1"
resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521" resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521"