mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
173 Commits
feat/searc
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96140e614c | ||
|
|
2ccc93ea61 | ||
|
|
7e7390885e | ||
|
|
64815f4f8d | ||
|
|
4dfdc4d798 | ||
|
|
9bbfab2aff | ||
|
|
01938f8905 | ||
|
|
f60ad5908d | ||
|
|
fe6553bcdc | ||
|
|
87895bb715 | ||
|
|
290209f372 | ||
|
|
87fcbaa56e | ||
|
|
c32ce14630 | ||
|
|
e52f10a5ff | ||
|
|
bcdbe31596 | ||
|
|
7ed514b6ef | ||
|
|
42386ae0b5 | ||
|
|
04d8f900a6 | ||
|
|
8b3bcd88b1 | ||
|
|
b2bffeb2b0 | ||
|
|
0a194eaa29 | ||
|
|
07c277c033 | ||
|
|
345696ad06 | ||
|
|
6c4e8c406f | ||
|
|
c46a1e7848 | ||
|
|
590e09a8c3 | ||
|
|
c1d7ea27f3 | ||
|
|
15dbd3b2ad | ||
|
|
4584783f44 | ||
|
|
765ec70dd0 | ||
|
|
de483da51c | ||
|
|
2bc0266775 | ||
|
|
c9729fb3eb | ||
|
|
9a7ad148e3 | ||
|
|
d929fbaeaa | ||
|
|
8fa33119d6 | ||
|
|
92d87c5d33 | ||
|
|
af884d3772 | ||
|
|
dc31ac0831 | ||
|
|
9769eecec6 | ||
|
|
91adb97013 | ||
|
|
f138b2efcb | ||
|
|
991aa05760 | ||
|
|
aff9e13bca | ||
|
|
240a75c1d0 | ||
|
|
edbe86a1fb | ||
|
|
a01e1b1709 | ||
|
|
60fd90820c | ||
|
|
798f88618e | ||
|
|
40795c34dc | ||
|
|
e335e05628 | ||
|
|
05464f25df | ||
|
|
b9830afca1 | ||
|
|
1cab73bcb4 | ||
|
|
27462c1e1e | ||
|
|
98dc20092a | ||
|
|
9faf34a976 | ||
|
|
d25ac69e74 | ||
|
|
d3fb967229 | ||
|
|
35736dd2d9 | ||
|
|
263d0be4e4 | ||
|
|
0b4d31e482 | ||
|
|
a74b557d13 | ||
|
|
86d5547aa1 | ||
|
|
358f41b4ba | ||
|
|
7f0dc5eee4 | ||
|
|
067f7a00be | ||
|
|
37f085e2c0 | ||
|
|
f8ac284bc2 | ||
|
|
cea5afc7f7 | ||
|
|
dff8d02f3f | ||
|
|
8751e369da | ||
|
|
45eaef23a9 | ||
|
|
3c296fe721 | ||
|
|
1d1bbd2de5 | ||
|
|
246fc14b75 | ||
|
|
387b3ebeac | ||
|
|
b1d72828bb | ||
|
|
4c09f915c6 | ||
|
|
24d65b50b4 | ||
|
|
f77b2116c1 | ||
|
|
6cd65d6239 | ||
|
|
34681b3bc2 | ||
|
|
4b71a3f5bb | ||
|
|
a0f669c97b | ||
|
|
96fca88601 | ||
|
|
3f65bb86a8 | ||
|
|
b494c7c8ec | ||
|
|
e10007b1c3 | ||
|
|
dae825a75a | ||
|
|
677f34fe3d | ||
|
|
1524e73ee6 | ||
|
|
40d428c19e | ||
|
|
affa7a2b2e | ||
|
|
cf16c8245c | ||
|
|
6257529297 | ||
|
|
b8352be274 | ||
|
|
d5e6bed3b7 | ||
|
|
214267df7e | ||
|
|
21f46c9af3 | ||
|
|
cc5e0014f7 | ||
|
|
ccb754fa13 | ||
|
|
142bd3156c | ||
|
|
95a7bc2236 | ||
|
|
78d2be85f2 | ||
|
|
67ea9e78a2 | ||
|
|
67f863e0f3 | ||
|
|
77b6f1b2ad | ||
|
|
5329cc446f | ||
|
|
21a0ad1500 | ||
|
|
9ffaee12d1 | ||
|
|
8555274589 | ||
|
|
a152c89d7f | ||
|
|
879f0baad7 | ||
|
|
c025dc199d | ||
|
|
1552a5f359 | ||
|
|
5b4b258526 | ||
|
|
0268829946 | ||
|
|
63f8289d0a | ||
|
|
0470958629 | ||
|
|
3b574e6578 | ||
|
|
7f28fc8ca1 | ||
|
|
d1eb174429 | ||
|
|
82a125237b | ||
|
|
19e312d31e | ||
|
|
79b1f05cde | ||
|
|
cc9ac9dc0f | ||
|
|
19406dd051 | ||
|
|
8aa6e113e7 | ||
|
|
3f41f0f7ad | ||
|
|
91ad4a68f7 | ||
|
|
a69a6ec510 | ||
|
|
fada6507c3 | ||
|
|
0479f1347b | ||
|
|
817870cdbb | ||
|
|
f44d5c8b49 | ||
|
|
c36109c092 | ||
|
|
b59fb7dc36 | ||
|
|
214a7af408 | ||
|
|
14679fc31e | ||
|
|
1545f42d17 | ||
|
|
e872b2ea8a | ||
|
|
dd7c84b433 | ||
|
|
1546da29cf | ||
|
|
a89b0bb2a8 | ||
|
|
9bdb216e0f | ||
|
|
9779aed8c1 | ||
|
|
058a148c7f | ||
|
|
16e3d52508 | ||
|
|
7e0002cf95 | ||
|
|
bf8b3ca836 | ||
|
|
77e376e742 | ||
|
|
bd28b202c4 | ||
|
|
153b954e78 | ||
|
|
a9e63730be | ||
|
|
316480930d | ||
|
|
0b5c9acaaa | ||
|
|
814a2da05c | ||
|
|
0ad1ebd6a2 | ||
|
|
e9de8264e2 | ||
|
|
b135087ffe | ||
|
|
b4a1af78a6 | ||
|
|
ede5bb0c23 | ||
|
|
9a27875cd8 | ||
|
|
cf20a942ae | ||
|
|
e176e624be | ||
|
|
2adc132c33 | ||
|
|
c4852b89f1 | ||
|
|
5bffaf17fa | ||
|
|
cc38be4383 | ||
|
|
0b70a28c08 | ||
|
|
3ff50a9932 | ||
|
|
83fbf20383 |
13
.cursorrules
13
.cursorrules
@@ -28,6 +28,19 @@
|
||||
- Use async/await instead of promises when possible
|
||||
- Prefer named exports over default exports for utilities and services
|
||||
|
||||
## ESLint Issues
|
||||
|
||||
- **Always try to fix ESLint errors properly before disabling rules**
|
||||
- When encountering ESLint errors, explore these solutions in order:
|
||||
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
|
||||
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
|
||||
3. **Only disable the rule as a last resort** when no reasonable solution exists
|
||||
- When disabling a rule, always include a comment explaining why it's necessary
|
||||
- Examples of proper fixes:
|
||||
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
|
||||
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
|
||||
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
|
||||
|
||||
## TypeScript Array Syntax
|
||||
|
||||
- **Always use `T[]` syntax instead of `Array<T>`** for array types
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
MAIN_VITE_API_URL=
|
||||
MAIN_VITE_AUTH_URL=
|
||||
MAIN_VITE_WS_URL=
|
||||
MAIN_VITE_NIMBUS_API_URL=
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
||||
MAIN_VITE_LAUNCHER_SUBDOMAIN=
|
||||
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -57,6 +57,7 @@ jobs:
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
||||
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -73,6 +74,7 @@ jobs:
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
|
||||
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -54,9 +54,10 @@ jobs:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
@@ -71,9 +72,10 @@ jobs:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
[](https://github.com/hydralauncher/hydra/releases)
|
||||
[](https://community.chocolatey.org/packages/hydralauncher)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.7.4",
|
||||
"version": "3.8.0",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -63,12 +63,14 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^20.5.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
"hls.js": "^1.5.12",
|
||||
"i18next": "^23.11.2",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"jsdom": "^24.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.544.0",
|
||||
"node-7z": "^3.0.0",
|
||||
"parse-torrent": "^11.0.18",
|
||||
"rc-virtual-list": "^3.18.3",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -89,6 +91,7 @@
|
||||
"user-agents": "^1.1.387",
|
||||
"uuid": "^13.0.0",
|
||||
"winreg": "^1.2.5",
|
||||
"workwonders-sdk": "0.0.10",
|
||||
"ws": "^8.18.1",
|
||||
"yaml": "^2.6.1",
|
||||
"yup": "^1.5.0"
|
||||
|
||||
2
proto
2
proto
Submodule proto updated: 7a23620f93...6f11c99c57
@@ -1,4 +1,5 @@
|
||||
import aria2p
|
||||
from aria2p.client import ClientException as DownloadNotFound
|
||||
|
||||
class HttpDownloader:
|
||||
def __init__(self):
|
||||
@@ -11,12 +12,16 @@ class HttpDownloader:
|
||||
)
|
||||
)
|
||||
|
||||
def start_download(self, url: str, save_path: str, header: str, out: str = None):
|
||||
def start_download(self, url: str, save_path: str, header, out: str = None):
|
||||
if self.download:
|
||||
self.aria2.resume([self.download])
|
||||
else:
|
||||
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
|
||||
|
||||
options = {"dir": save_path}
|
||||
if header:
|
||||
options["header"] = header
|
||||
if out:
|
||||
options["out"] = out
|
||||
downloads = self.aria2.add(url, options=options)
|
||||
self.download = downloads[0]
|
||||
|
||||
def pause_download(self):
|
||||
@@ -32,7 +37,11 @@ class HttpDownloader:
|
||||
if self.download == None:
|
||||
return None
|
||||
|
||||
download = self.aria2.get_download(self.download.gid)
|
||||
try:
|
||||
download = self.aria2.get_download(self.download.gid)
|
||||
except DownloadNotFound:
|
||||
self.download = None
|
||||
return None
|
||||
|
||||
response = {
|
||||
'folderName': download.name,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"game_has_no_executable": "Game has no executable selected",
|
||||
"sign_in": "Sign in",
|
||||
"friends": "Friends",
|
||||
"notifications": "Notifications",
|
||||
"need_help": "Need help?",
|
||||
"favorites": "Favorites",
|
||||
"playable_button_title": "Show only games you can play now",
|
||||
@@ -115,6 +116,7 @@
|
||||
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
|
||||
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
|
||||
"extracting": "Extracting {{title}}… ({{percentage}} complete)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Installation complete",
|
||||
"installation_complete_message": "Common redistributables installed successfully"
|
||||
@@ -173,6 +175,7 @@
|
||||
"repacks_modal_description": "Choose the repack you want to download",
|
||||
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
|
||||
"download_now": "Download now",
|
||||
"loading": "Loading...",
|
||||
"no_shop_details": "Could not retrieve shop details.",
|
||||
"download_options": "Download options",
|
||||
"download_path": "Download path",
|
||||
@@ -182,6 +185,12 @@
|
||||
"open_screenshot": "Open screenshot {{number}}",
|
||||
"download_settings": "Download settings",
|
||||
"downloader": "Downloader",
|
||||
"downloader_online": "Online",
|
||||
"downloader_not_configured": "Available but not configured",
|
||||
"downloader_offline": "Link is offline",
|
||||
"downloader_not_available": "Not available",
|
||||
"recommended": "Recommended",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"select_executable": "Select",
|
||||
"no_executable_selected": "No executable selected",
|
||||
"open_folder": "Open folder",
|
||||
@@ -202,6 +211,7 @@
|
||||
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
|
||||
"download_in_progress": "Download in progress",
|
||||
"download_paused": "Download paused",
|
||||
"extracting": "Extracting",
|
||||
"last_downloaded_option": "Last downloaded option",
|
||||
"new_download_option": "New",
|
||||
"create_steam_shortcut": "Create Steam shortcut",
|
||||
@@ -414,7 +424,13 @@
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage",
|
||||
"extract": "Extract files",
|
||||
"extracting": "Extracting files…"
|
||||
"extracting": "Extracting files…",
|
||||
"delete_archive_title": "Would you like to delete {{fileName}}?",
|
||||
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"network": "NETWORK",
|
||||
"peak": "PEAK"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
@@ -550,6 +566,7 @@
|
||||
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
|
||||
"extract_files_by_default": "Extract files by default after download",
|
||||
"enable_steam_achievements": "Enable search for Steam achievements",
|
||||
"enable_new_download_options_badges": "Show new download options badges",
|
||||
"achievement_custom_notification_position": "Achievement custom notification position",
|
||||
"top-left": "Top left",
|
||||
"top-center": "Top center",
|
||||
@@ -654,6 +671,7 @@
|
||||
"sending": "Sending",
|
||||
"friend_request_sent": "Friend request sent",
|
||||
"friends": "Friends",
|
||||
"badges": "Badges",
|
||||
"friends_list": "Friends list",
|
||||
"user_not_found": "User not found",
|
||||
"block_user": "Block user",
|
||||
@@ -664,12 +682,17 @@
|
||||
"ignore_request": "Ignore request",
|
||||
"cancel_request": "Cancel request",
|
||||
"undo_friendship": "Undo friendship",
|
||||
"friendship_removed": "Friend removed",
|
||||
"request_accepted": "Request accepted",
|
||||
"user_blocked_successfully": "User blocked successfully",
|
||||
"user_block_modal_text": "This will block {{displayName}}",
|
||||
"blocked_users": "Blocked users",
|
||||
"unblock": "Unblock",
|
||||
"no_friends_added": "You have no added friends",
|
||||
"no_friends_yet": "You haven't added any friends yet",
|
||||
"view_all": "View all",
|
||||
"load_more": "Load more",
|
||||
"loading": "Loading",
|
||||
"pending": "Pending",
|
||||
"no_pending_invites": "You have no pending invites",
|
||||
"no_blocked_users": "You have no blocked users",
|
||||
@@ -693,6 +716,7 @@
|
||||
"report_reason_other": "Other",
|
||||
"profile_reported": "Profile reported",
|
||||
"your_friend_code": "Your friend code:",
|
||||
"copy_friend_code": "Copy friend code",
|
||||
"upload_banner": "Upload banner",
|
||||
"uploading_banner": "Uploading banner…",
|
||||
"background_image_updated": "Background image updated",
|
||||
@@ -712,10 +736,12 @@
|
||||
"game_added_to_pinned": "Game added to pinned",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Earned from positive likes on reviews",
|
||||
"user_reviews": "Reviews",
|
||||
"delete_review": "Delete Review",
|
||||
"loading_reviews": "Loading reviews..."
|
||||
"loading_reviews": "Loading reviews...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "View My Wrapped 2025",
|
||||
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
|
||||
},
|
||||
"library": {
|
||||
"library": "Library",
|
||||
@@ -766,5 +792,41 @@
|
||||
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
||||
"learn_more": "Learn More",
|
||||
"debrid_description": "Download up to 4x faster with Nimbus"
|
||||
},
|
||||
"notifications_page": {
|
||||
"title": "Notifications",
|
||||
"mark_all_as_read": "Mark all as read",
|
||||
"clear_all": "Clear All",
|
||||
"loading": "Loading...",
|
||||
"empty_title": "No notifications",
|
||||
"empty_description": "You're all caught up! Check back later for new updates.",
|
||||
"empty_filter_description": "No notifications match this filter.",
|
||||
"filter_all": "All",
|
||||
"filter_unread": "Unread",
|
||||
"filter_friends": "Friends",
|
||||
"filter_badges": "Badges",
|
||||
"filter_upvotes": "Upvotes",
|
||||
"filter_local": "Local",
|
||||
"load_more": "Load more",
|
||||
"dismiss": "Dismiss",
|
||||
"accept": "Accept",
|
||||
"refuse": "Refuse",
|
||||
"notification": "Notification",
|
||||
"friend_request_received_title": "New friend request!",
|
||||
"friend_request_received_description": "{{displayName}} wants to be your friend",
|
||||
"friend_request_accepted_title": "Friend request accepted!",
|
||||
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
|
||||
"badge_received_title": "You got a new badge!",
|
||||
"badge_received_description": "{{badgeName}}",
|
||||
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
|
||||
"review_upvote_description": "Your review received {{count}} new upvotes",
|
||||
"marked_all_as_read": "All notifications marked as read",
|
||||
"failed_to_mark_as_read": "Failed to mark notifications as read",
|
||||
"cleared_all": "All notifications cleared",
|
||||
"failed_to_clear": "Failed to clear notifications",
|
||||
"failed_to_load": "Failed to load notifications",
|
||||
"failed_to_dismiss": "Failed to dismiss notification",
|
||||
"friend_request_accepted": "Friend request accepted",
|
||||
"friend_request_refused": "Friend request refused"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"open_screenshot": "Abrir captura número {{number}}",
|
||||
"download_settings": "Descargar ajustes",
|
||||
"downloader": "Descargador",
|
||||
"downloader_online": "En línea",
|
||||
"downloader_not_configured": "Disponible pero no configurado",
|
||||
"downloader_offline": "El enlace está fuera de línea",
|
||||
"downloader_not_available": "No disponible",
|
||||
"recommended": "Recomendado",
|
||||
"go_to_settings": "Ir a Ajustes",
|
||||
"select_executable": "Seleccionar",
|
||||
"no_executable_selected": "Sin ejecutable seleccionado",
|
||||
"open_folder": "Abrir carpeta",
|
||||
@@ -458,6 +464,7 @@
|
||||
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
|
||||
"button_delete_all_sources": "Eliminar todo",
|
||||
"added_download_source": "Añadir fuente de descarga",
|
||||
"adding": "Añadiendo…",
|
||||
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
|
||||
"insert_valid_json_url": "Introducí una URL de json válida",
|
||||
"found_download_option_zero": "Sin opciones de descargas encontrada",
|
||||
@@ -563,6 +570,19 @@
|
||||
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
|
||||
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
|
||||
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
|
||||
"change_achievement_sound": "Cambiar sonido de logro",
|
||||
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
|
||||
"download_source_failed": "Error",
|
||||
"download_source_matched": "Actualizado",
|
||||
"download_source_matching": "Actualizando",
|
||||
"download_source_no_information": "Sin información disponible",
|
||||
"download_source_pending_matching": "Actualizando pronto",
|
||||
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
|
||||
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"preview_sound": "Vista previa de sonido",
|
||||
"remove_achievement_sound": "Eliminar sonido de logros",
|
||||
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
|
||||
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -637,6 +657,7 @@
|
||||
"sending": "Enviando",
|
||||
"friend_request_sent": "Solicitud de amistad enviada",
|
||||
"friends": "Amistades",
|
||||
"badges": "Insignias",
|
||||
"friends_list": "Lista de amistades",
|
||||
"user_not_found": "Usuario no encontrado",
|
||||
"block_user": "Bloquear usuario",
|
||||
@@ -647,12 +668,16 @@
|
||||
"ignore_request": "Ignorar solicitud",
|
||||
"cancel_request": "Cancelar solicitud",
|
||||
"undo_friendship": "Deshacer amistad",
|
||||
"friendship_removed": "Amigo eliminado",
|
||||
"request_accepted": "Solicitud aceptada",
|
||||
"user_blocked_successfully": "Usuario bloqueado exitosamente",
|
||||
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
|
||||
"blocked_users": "Usuarios bloqueados",
|
||||
"unblock": "Desbloquear",
|
||||
"no_friends_added": "No tenés amistades añadidas",
|
||||
"view_all": "Ver todo",
|
||||
"load_more": "Cargar más",
|
||||
"loading": "Cargando",
|
||||
"pending": "Pendiente",
|
||||
"no_pending_invites": "No tenés invitaciones pendientes",
|
||||
"no_blocked_users": "No has bloqueado a nadie",
|
||||
@@ -676,6 +701,7 @@
|
||||
"report_reason_other": "Otros",
|
||||
"profile_reported": "Perfil reportado",
|
||||
"your_friend_code": "Tu código de amistad:",
|
||||
"copy_friend_code": "Copiar código de amistad",
|
||||
"upload_banner": "Subir banner",
|
||||
"uploading_banner": "Subiendo banner…",
|
||||
"background_image_updated": "Imagen de fondo actualizada",
|
||||
@@ -696,11 +722,13 @@
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Conseguido por me gustas positivos en reseñas",
|
||||
"sort_by": "Filtrar por:",
|
||||
"game_added_to_pinned": "Juego añadido a fijados",
|
||||
"user_reviews": "Reseñas",
|
||||
"loading_reviews": "Cargando reseñas...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "Ver Mi Wrapped 2025",
|
||||
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
|
||||
"no_reviews": "Sin reseñas aún",
|
||||
"delete_review": "Eliminar reseña"
|
||||
},
|
||||
@@ -753,5 +781,41 @@
|
||||
"all_games": "Todos los Juegos",
|
||||
"recently_played": "Jugados Recientemente",
|
||||
"favorites": "Favoritos"
|
||||
},
|
||||
"notifications_page": {
|
||||
"title": "Notificaciones",
|
||||
"mark_all_as_read": "Marcar todo como leído",
|
||||
"clear_all": "Limpiar todo",
|
||||
"loading": "Cargando...",
|
||||
"empty_title": "Sin notificaciones",
|
||||
"empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.",
|
||||
"empty_filter_description": "No hay notificaciones que coincidan con este filtro.",
|
||||
"filter_all": "Todas",
|
||||
"filter_unread": "No leídas",
|
||||
"filter_friends": "Amigos",
|
||||
"filter_badges": "Insignias",
|
||||
"filter_upvotes": "Votos",
|
||||
"filter_local": "Locales",
|
||||
"load_more": "Cargar más",
|
||||
"dismiss": "Descartar",
|
||||
"accept": "Aceptar",
|
||||
"refuse": "Rechazar",
|
||||
"notification": "Notificación",
|
||||
"friend_request_received_title": "¡Nueva solicitud de amistad!",
|
||||
"friend_request_received_description": "{{displayName}} quiere ser tu amigo",
|
||||
"friend_request_accepted_title": "¡Solicitud de amistad aceptada!",
|
||||
"friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad",
|
||||
"badge_received_title": "¡Obtuviste una nueva insignia!",
|
||||
"badge_received_description": "{{badgeName}}",
|
||||
"review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!",
|
||||
"review_upvote_description": "Tu reseña recibió {{count}} nuevos votos",
|
||||
"marked_all_as_read": "Todas las notificaciones marcadas como leídas",
|
||||
"failed_to_mark_as_read": "Error al marcar las notificaciones como leídas",
|
||||
"cleared_all": "Todas las notificaciones eliminadas",
|
||||
"failed_to_clear": "Error al eliminar las notificaciones",
|
||||
"failed_to_load": "Error al cargar las notificaciones",
|
||||
"failed_to_dismiss": "Error al descartar la notificación",
|
||||
"friend_request_accepted": "Solicitud de amistad aceptada",
|
||||
"friend_request_refused": "Solicitud de amistad rechazada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,8 +673,7 @@
|
||||
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
|
||||
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karmaa",
|
||||
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
|
||||
"karma_count": "karmaa"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Saavutus avattu",
|
||||
|
||||
@@ -27,7 +27,69 @@
|
||||
"friends": "Amis",
|
||||
"need_help": "Besoin d'aide ?",
|
||||
"favorites": "Favoris",
|
||||
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
|
||||
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
|
||||
"library": "Bibliothèque",
|
||||
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
|
||||
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
|
||||
"custom_game_modal": "Ajouter un jeu personnalisé",
|
||||
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
|
||||
"custom_game_modal_executable_path": "Chemin de l'exécutable",
|
||||
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
|
||||
"custom_game_modal_title": "Titre",
|
||||
"custom_game_modal_enter_title": "Entrer le titre",
|
||||
"custom_game_modal_browse": "Parcourir",
|
||||
"custom_game_modal_cancel": "Annuler",
|
||||
"custom_game_modal_add": "Ajouter le jeu",
|
||||
"custom_game_modal_adding": "Ajout du jeu…",
|
||||
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
|
||||
"custom_game_modal_failed": "Échec de l’ajout du jeu personnalisé",
|
||||
"custom_game_modal_executable": "Exécutable",
|
||||
"edit_game_modal": "Personnaliser les ressources",
|
||||
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
|
||||
"edit_game_modal_title": "Titre",
|
||||
"edit_game_modal_enter_title": "Entrer le titre",
|
||||
"edit_game_modal_image": "Image",
|
||||
"edit_game_modal_select_image": "Sélectionner une image",
|
||||
"edit_game_modal_browse": "Parcourir",
|
||||
"edit_game_modal_image_preview": "Aperçu de l’image",
|
||||
"edit_game_modal_icon": "Icône",
|
||||
"edit_game_modal_select_icon": "Sélectionner une icône",
|
||||
"edit_game_modal_icon_preview": "Aperçu de l’icône",
|
||||
"edit_game_modal_logo": "Logo",
|
||||
"edit_game_modal_select_logo": "Sélectionner un logo",
|
||||
"edit_game_modal_logo_preview": "Aperçu du logo",
|
||||
"edit_game_modal_hero": "Bannière de la bibliothèque",
|
||||
"edit_game_modal_select_hero": "Sélectionner l’image de bannière",
|
||||
"edit_game_modal_hero_preview": "Aperçu de la bannière",
|
||||
"edit_game_modal_cancel": "Annuler",
|
||||
"edit_game_modal_update": "Mettre à jour",
|
||||
"edit_game_modal_updating": "Mise à jour…",
|
||||
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
|
||||
"edit_game_modal_success": "Ressources mises à jour avec succès",
|
||||
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
|
||||
"edit_game_modal_image_filter": "Image",
|
||||
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
|
||||
"edit_game_modal_assets": "Ressources",
|
||||
"edit_game_modal_drop_icon_image_here": "Déposez l’image de l’icône ici",
|
||||
"edit_game_modal_drop_logo_image_here": "Déposez l’image du logo ici",
|
||||
"edit_game_modal_drop_hero_image_here": "Déposez l’image de la bannière ici",
|
||||
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer l’icône",
|
||||
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
|
||||
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
|
||||
"install_decky_plugin": "Installer le plugin Decky",
|
||||
"update_decky_plugin": "Mettre à jour le plugin Decky",
|
||||
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
|
||||
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
|
||||
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
|
||||
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
|
||||
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
|
||||
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
|
||||
"decky_plugin_installation_failed": "Échec de l’installation du plugin Decky : {{error}}",
|
||||
"decky_plugin_installation_error": "Erreur lors de l’installation du plugin Decky : {{error}}",
|
||||
"confirm": "Confirmer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"header": {
|
||||
"search": "Rechercher",
|
||||
@@ -37,7 +99,15 @@
|
||||
"search_results": "Résultats de la recherche",
|
||||
"settings": "Paramètres",
|
||||
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
|
||||
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
|
||||
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.",
|
||||
"search_library": "Rechercher dans la bibliothèque",
|
||||
"recent_searches": "Recherches récentes",
|
||||
"suggestions": "Suggestions",
|
||||
"clear_history": "Effacer",
|
||||
"remove_from_history": "Supprimer de l'historique",
|
||||
"loading": "Chargement…",
|
||||
"no_results": "Aucun résultat",
|
||||
"library": "Bibliothèque"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Aucun téléchargement en cours",
|
||||
@@ -47,7 +117,8 @@
|
||||
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Installation terminée",
|
||||
"installation_complete_message": "Redistribuables communs installés avec succès"
|
||||
"installation_complete_message": "Redistribuables communs installés avec succès",
|
||||
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
|
||||
},
|
||||
"catalogue": {
|
||||
"search": "Filtrer…",
|
||||
@@ -198,7 +269,113 @@
|
||||
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
|
||||
"game_removed_from_favorites": "Jeu retiré des favoris",
|
||||
"game_added_to_favorites": "Jeu ajouté aux favoris",
|
||||
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
|
||||
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés",
|
||||
"already_in_library": "Déjà dans la bibliothèque",
|
||||
"create_shortcut_simple": "Créer un raccourci",
|
||||
"properties": "Propriétés",
|
||||
"extracting": "Extraction en cours",
|
||||
"new_download_option": "Nouveau",
|
||||
"create_steam_shortcut": "Créer un raccourci Steam",
|
||||
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
|
||||
"add_to_favorites": "Ajouter aux favoris",
|
||||
"remove_from_favorites": "Retirer des favoris",
|
||||
"failed_update_favorites": "Échec de la mise à jour des favoris",
|
||||
"game_removed_from_library": "Jeu retiré de la bibliothèque",
|
||||
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
|
||||
"files_removed_success": "Fichiers supprimés avec succès",
|
||||
"failed_remove_files": "Échec de la suppression des fichiers",
|
||||
"rating_count": "Évaluations",
|
||||
"show_more": "Afficher plus",
|
||||
"show_less": "Afficher moins",
|
||||
"reviews": "Avis",
|
||||
"review_played_for": "Temps de jeu",
|
||||
"leave_a_review": "Laisser un avis",
|
||||
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
|
||||
"sort_newest": "Les plus récents",
|
||||
"sort_oldest": "Les plus anciens",
|
||||
"sort_highest_score": "Meilleure note",
|
||||
"sort_lowest_score": "Note la plus basse",
|
||||
"sort_most_voted": "Les plus votés",
|
||||
"no_reviews_yet": "Aucun avis pour le moment",
|
||||
"be_first_to_review": "Soyez le premier à donner votre avis !",
|
||||
"rating": "Note",
|
||||
"rating_stats": "Évaluation",
|
||||
"rating_very_negative": "Très négatif",
|
||||
"rating_negative": "Négatif",
|
||||
"rating_neutral": "Neutre",
|
||||
"rating_positive": "Positif",
|
||||
"rating_very_positive": "Très positif",
|
||||
"submit_review": "Envoyer",
|
||||
"submitting": "Envoi…",
|
||||
"review_submitted_successfully": "Avis envoyé avec succès !",
|
||||
"review_submission_failed": "Échec de l’envoi de l’avis. Veuillez réessayer.",
|
||||
"review_cannot_be_empty": "Le champ de l’avis ne peut pas être vide.",
|
||||
"review_deleted_successfully": "Avis supprimé avec succès.",
|
||||
"review_deletion_failed": "Échec de la suppression de l’avis.",
|
||||
"loading_reviews": "Chargement des avis…",
|
||||
"loading_more_reviews": "Chargement de plus d’avis…",
|
||||
"load_more_reviews": "Charger plus d’avis",
|
||||
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
|
||||
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
|
||||
"yes": "Oui",
|
||||
"maybe_later": "Peut-être plus tard",
|
||||
"backup_failed": "Échec de la sauvegarde",
|
||||
"update_playtime_title": "Mettre à jour le temps de jeu",
|
||||
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
|
||||
"update_playtime": "Mettre à jour le temps de jeu",
|
||||
"update_playtime_success": "Temps de jeu mis à jour avec succès",
|
||||
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
|
||||
"update_game_playtime": "Mettre à jour le temps de jeu",
|
||||
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
|
||||
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
|
||||
"game_removed_from_pinned": "Jeu retiré des épinglés",
|
||||
"game_added_to_pinned": "Jeu ajouté aux épinglés",
|
||||
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
|
||||
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
|
||||
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
|
||||
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
|
||||
"artifact_renamed": "Sauvegarde renommée avec succès",
|
||||
"rename_artifact": "Renommer la sauvegarde",
|
||||
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
|
||||
"artifact_name_label": "Nom de la sauvegarde",
|
||||
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"required_field": "Ce champ est requis",
|
||||
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
|
||||
"freeze_backup": "Épingler pour éviter l’écrasement automatique",
|
||||
"unfreeze_backup": "Désépingler",
|
||||
"backup_frozen": "Sauvegarde épinglée",
|
||||
"backup_unfrozen": "Sauvegarde désépinglée",
|
||||
"backup_freeze_failed": "Échec de l’épinglage de la sauvegarde",
|
||||
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
|
||||
"edit_game_modal_button": "Personnaliser les ressources du jeu",
|
||||
"game_details": "Détails du jeu",
|
||||
"prices": "Prix",
|
||||
"no_prices_found": "Aucun prix trouvé",
|
||||
"view_all_prices": "Cliquer pour voir tous les prix",
|
||||
"retail_price": "Prix officiel",
|
||||
"keyshop_price": "Prix Keyshop",
|
||||
"historical_retail": "Historique officiel",
|
||||
"historical_keyshop": "Historique Keyshop",
|
||||
"language": "Langue",
|
||||
"caption": "Sous-titres",
|
||||
"audio": "Audio",
|
||||
"filter_by_source": "Filtrer par source",
|
||||
"no_repacks_found": "Aucune source trouvée pour ce jeu",
|
||||
"delete_review": "Supprimer l’avis",
|
||||
"remove_review": "Retirer l’avis",
|
||||
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
|
||||
"delete_review_modal_description": "Cette action est irréversible.",
|
||||
"delete_review_modal_delete_button": "Supprimer",
|
||||
"delete_review_modal_cancel_button": "Annuler",
|
||||
"vote_failed": "Échec de l’enregistrement de votre vote. Veuillez réessayer.",
|
||||
"show_original": "Afficher l’original",
|
||||
"show_translation": "Afficher la traduction",
|
||||
"show_original_translated_from": "Afficher l’original (traduit depuis {{language}})",
|
||||
"hide_original": "Masquer l’original",
|
||||
"review_from_blocked_user": "Avis d’un utilisateur bloqué",
|
||||
"show": "Afficher",
|
||||
"hide": "Masquer"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activer Hydra",
|
||||
@@ -237,7 +414,11 @@
|
||||
"resume_seeding": "Reprendre le partage",
|
||||
"options": "Gérer",
|
||||
"extract": "Extraire les fichiers",
|
||||
"extracting": "Extraction des fichiers…"
|
||||
"extracting": "Extraction des fichiers…",
|
||||
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
|
||||
"delete_archive_description": "Le fichier a été extrait avec succès et n’est plus nécessaire.",
|
||||
"yes": "Oui",
|
||||
"no": "Non"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Chemin des téléchargements",
|
||||
@@ -366,7 +547,40 @@
|
||||
"bottom-left": "En bas à gauche",
|
||||
"bottom-center": "En bas au centre",
|
||||
"bottom-right": "En bas à droite",
|
||||
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
|
||||
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu",
|
||||
"adding": "Ajout…",
|
||||
"failed_add_download_source": "Échec de l’ajout de la source de téléchargement. Veuillez réessayer.",
|
||||
"download_source_already_exists": "Cette URL de source existe déjà",
|
||||
"download_source_pending_matching": "Mise à jour imminente",
|
||||
"download_source_matched": "À jour",
|
||||
"download_source_matching": "Mise à jour",
|
||||
"download_source_failed": "Erreur",
|
||||
"download_source_no_information": "Aucune information disponible",
|
||||
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
|
||||
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
|
||||
"importing": "Importation…",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"debrid": "Debrid",
|
||||
"enable_steam_achievements": "Activer la recherche de succès Steam",
|
||||
"alignment": "Alignement",
|
||||
"variation": "Variation",
|
||||
"default": "Par défaut",
|
||||
"rare": "Rare",
|
||||
"platinum": "Platine",
|
||||
"hidden": "Caché",
|
||||
"test_notification": "Notification de test",
|
||||
"achievement_sound_volume": "Volume du son de succès",
|
||||
"select_achievement_sound": "Sélectionner un son de succès",
|
||||
"change_achievement_sound": "Changer le son de succès",
|
||||
"remove_achievement_sound": "Supprimer le son de succès",
|
||||
"preview_sound": "Prévisualiser le son",
|
||||
"select": "Sélectionner",
|
||||
"preview": "Aperçu",
|
||||
"remove": "Supprimer",
|
||||
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
|
||||
"notification_preview": "Aperçu de la notification de succès",
|
||||
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
|
||||
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement d’un jeu"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Téléchargement terminé",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
||||
"filter": "Könyvtár szűrése",
|
||||
"home": "Főoldal",
|
||||
"queued": "A(z) {{title}} (Várakozósorban van)",
|
||||
"queued": "{{title}} (Várakozásban)",
|
||||
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
||||
"sign_in": "Bejelentkezés",
|
||||
"friends": "Barátok",
|
||||
@@ -94,6 +94,12 @@
|
||||
"header": {
|
||||
"search": "Keresés",
|
||||
"search_library": "Könyvtár böngészése",
|
||||
"recent_searches": "Korábbi Keresések",
|
||||
"suggestions": "Találatok",
|
||||
"clear_history": "Törlés",
|
||||
"remove_from_history": "Törlés az előzményekből",
|
||||
"loading": "Töltés...",
|
||||
"no_results": "Nincs találat",
|
||||
"home": "Főoldal",
|
||||
"catalogue": "Katalógus",
|
||||
"library": "Könyvtár",
|
||||
@@ -109,6 +115,7 @@
|
||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
||||
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
||||
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
||||
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Telepítés befejezve",
|
||||
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
||||
@@ -165,7 +172,7 @@
|
||||
"playing_now": "Játékban: ",
|
||||
"change": "Változtatás",
|
||||
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
||||
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
|
||||
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
|
||||
"download_now": "Letöltés",
|
||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||
"download_options": "Letöltési opciók",
|
||||
@@ -196,6 +203,7 @@
|
||||
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
||||
"download_in_progress": "Letöltés folyamatban",
|
||||
"download_paused": "Letöltés szüneteltetve",
|
||||
"extracting": "Kicsomagolás",
|
||||
"last_downloaded_option": "Utoljára letöltött",
|
||||
"new_download_option": "Új",
|
||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||
@@ -397,7 +405,7 @@
|
||||
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
||||
"install": "Telepít",
|
||||
"download_in_progress": "Folyamatban lévő",
|
||||
"queued_downloads": "Várakozósoron lévő letöltések",
|
||||
"queued_downloads": "Várakozásban lévő letöltések",
|
||||
"downloads_completed": "Befejezett",
|
||||
"queued": "Várakozásban",
|
||||
"no_downloads_title": "Oly üres..",
|
||||
@@ -408,7 +416,11 @@
|
||||
"resume_seeding": "Seedelés folytatása",
|
||||
"options": "Kezelés",
|
||||
"extract": "Fájlok kibontása",
|
||||
"extracting": "Fájlok kibontása…"
|
||||
"extracting": "Fájlok kibontása…",
|
||||
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
|
||||
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ",
|
||||
"yes": "Igen",
|
||||
"no": "Nem"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Letöltési útvonalak",
|
||||
@@ -669,7 +681,7 @@
|
||||
"no_blocked_users": "Nincs letiltott felhasználó",
|
||||
"friend_code_copied": "Barát kód kimásolva",
|
||||
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
|
||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
|
||||
"locked_profile": "Ez a profil privát",
|
||||
"image_process_failure": "Hiba a kép feldolgozása közben",
|
||||
"required_field": "Ez a mező kötelező",
|
||||
@@ -706,7 +718,6 @@
|
||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Pozitív értékelésekkel szerzett pontok",
|
||||
"user_reviews": "Vélemények",
|
||||
"delete_review": "Vélemény Törlése",
|
||||
"loading_reviews": "Vélemények betöltése..."
|
||||
|
||||
@@ -673,8 +673,7 @@
|
||||
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
|
||||
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
|
||||
"karma_count": "karma"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Sasniegums atbloķēts",
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
|
||||
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
|
||||
"checking_files": "Verificando arquivos de {{title}}…",
|
||||
"extracting": "Extraindo {{title}}… ({{percentage}} concluído)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Instalação concluída",
|
||||
"installation_complete_message": "Componentes recomendados instalados com sucesso"
|
||||
@@ -171,6 +172,12 @@
|
||||
"open_screenshot": "Ver captura de tela {{number}}",
|
||||
"download_settings": "Ajustes do download",
|
||||
"downloader": "Downloader",
|
||||
"downloader_online": "Online",
|
||||
"downloader_not_configured": "Disponível mas não configurado",
|
||||
"downloader_offline": "Link está offline",
|
||||
"downloader_not_available": "Não disponível",
|
||||
"recommended": "Recomendado",
|
||||
"go_to_settings": "Ir para Configurações",
|
||||
"select_executable": "Explorar",
|
||||
"no_executable_selected": "Nenhum executável selecionado",
|
||||
"open_folder": "Abrir pasta",
|
||||
@@ -190,6 +197,7 @@
|
||||
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
|
||||
"download_in_progress": "Download em andamento",
|
||||
"download_paused": "Download pausado",
|
||||
"extracting": "Extraindo",
|
||||
"last_downloaded_option": "Última opção baixada",
|
||||
"new_download_option": "Novo",
|
||||
"create_steam_shortcut": "Criar atalho na Steam",
|
||||
@@ -402,7 +410,13 @@
|
||||
"resume_seeding": "Semear",
|
||||
"options": "Gerenciar",
|
||||
"extract": "Extrair arquivos",
|
||||
"extracting": "Extraindo arquivos…"
|
||||
"extracting": "Extraindo arquivos…",
|
||||
"delete_archive_title": "Deseja deletar {{fileName}}?",
|
||||
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"network": "REDE",
|
||||
"peak": "PICO"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
@@ -646,6 +660,7 @@
|
||||
"see_profile": "Ver perfil",
|
||||
"friend_request_sent": "Pedido de amizade enviado",
|
||||
"friends": "Amigos",
|
||||
"badges": "Insígnias",
|
||||
"add": "Adicionar",
|
||||
"sending": "Enviando",
|
||||
"friends_list": "Lista de amigos",
|
||||
@@ -658,12 +673,16 @@
|
||||
"ignore_request": "Ignorar pedido",
|
||||
"cancel_request": "Cancelar pedido",
|
||||
"undo_friendship": "Desfazer amizade",
|
||||
"friendship_removed": "Amigo removido",
|
||||
"request_accepted": "Pedido de amizade aceito",
|
||||
"user_blocked_successfully": "Usuário bloqueado com sucesso",
|
||||
"user_block_modal_text": "Bloquear {{displayName}}",
|
||||
"blocked_users": "Usuários bloqueados",
|
||||
"unblock": "Desbloquear",
|
||||
"no_friends_added": "Você ainda não possui amigos adicionados",
|
||||
"view_all": "Ver todos",
|
||||
"load_more": "Carregar mais",
|
||||
"loading": "Carregando",
|
||||
"pending": "Pendentes",
|
||||
"no_pending_invites": "Você não possui convites de amizade pendentes",
|
||||
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
|
||||
@@ -687,6 +706,7 @@
|
||||
"report_reason_other": "Outro",
|
||||
"profile_reported": "Perfil reportado",
|
||||
"your_friend_code": "Seu código de amigo:",
|
||||
"copy_friend_code": "Copiar código de amigo",
|
||||
"upload_banner": "Carregar banner",
|
||||
"uploading_banner": "Carregando banner…",
|
||||
"background_image_updated": "Imagem de fundo salva",
|
||||
@@ -712,10 +732,12 @@
|
||||
"achievements_earned": "Conquistas recebidas",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
|
||||
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
|
||||
"user_reviews": "Avaliações",
|
||||
"loading_reviews": "Carregando avaliações...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "Ver Meu Wrapped 2025",
|
||||
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
|
||||
"no_reviews": "Ainda não há avaliações",
|
||||
"delete_review": "Excluir avaliação"
|
||||
},
|
||||
@@ -768,5 +790,41 @@
|
||||
"all_games": "Todos os Jogos",
|
||||
"recently_played": "Jogados Recentemente",
|
||||
"favorites": "Favoritos"
|
||||
},
|
||||
"notifications_page": {
|
||||
"title": "Notificações",
|
||||
"mark_all_as_read": "Marcar todas como lidas",
|
||||
"clear_all": "Limpar todas",
|
||||
"loading": "Carregando...",
|
||||
"empty_title": "Sem notificações",
|
||||
"empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.",
|
||||
"empty_filter_description": "Nenhuma notificação corresponde a este filtro.",
|
||||
"filter_all": "Todas",
|
||||
"filter_unread": "Não lidas",
|
||||
"filter_friends": "Amigos",
|
||||
"filter_badges": "Insígnias",
|
||||
"filter_upvotes": "Votos",
|
||||
"filter_local": "Locais",
|
||||
"load_more": "Carregar mais",
|
||||
"dismiss": "Descartar",
|
||||
"accept": "Aceitar",
|
||||
"refuse": "Recusar",
|
||||
"notification": "Notificação",
|
||||
"friend_request_received_title": "Nova solicitação de amizade!",
|
||||
"friend_request_received_description": "{{displayName}} quer ser seu amigo",
|
||||
"friend_request_accepted_title": "Solicitação de amizade aceita!",
|
||||
"friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade",
|
||||
"badge_received_title": "Você recebeu uma nova insígnia!",
|
||||
"badge_received_description": "{{badgeName}}",
|
||||
"review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!",
|
||||
"review_upvote_description": "Sua avaliação recebeu {{count}} novos votos",
|
||||
"marked_all_as_read": "Todas as notificações marcadas como lidas",
|
||||
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
|
||||
"cleared_all": "Todas as notificações limpas",
|
||||
"failed_to_clear": "Falha ao limpar notificações",
|
||||
"failed_to_load": "Falha ao carregar notificações",
|
||||
"failed_to_dismiss": "Falha ao descartar notificação",
|
||||
"friend_request_accepted": "Solicitação de amizade aceita",
|
||||
"friend_request_refused": "Solicitação de amizade recusada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,12 @@
|
||||
"open_screenshot": "Открыть скриншот {{number}}",
|
||||
"download_settings": "Параметры загрузки",
|
||||
"downloader": "Загрузчик",
|
||||
"downloader_online": "Онлайн",
|
||||
"downloader_not_configured": "Доступен, но не настроен",
|
||||
"downloader_offline": "Ссылка недоступна",
|
||||
"downloader_not_available": "Недоступно",
|
||||
"recommended": "Рекомендуется",
|
||||
"go_to_settings": "Перейти в настройки",
|
||||
"select_executable": "Выбрать",
|
||||
"no_executable_selected": "Файл не выбран",
|
||||
"open_folder": "Открыть папку",
|
||||
@@ -651,6 +657,7 @@
|
||||
"sending": "Отправка",
|
||||
"friend_request_sent": "Запрос в друзья отправлен",
|
||||
"friends": "Друзья",
|
||||
"badges": "Значки",
|
||||
"friends_list": "Список друзей",
|
||||
"user_not_found": "Пользователь не найден",
|
||||
"block_user": "Заблокировать пользователя",
|
||||
@@ -661,12 +668,16 @@
|
||||
"ignore_request": "Игнорировать запрос",
|
||||
"cancel_request": "Отменить запрос",
|
||||
"undo_friendship": "Удалить друга",
|
||||
"friendship_removed": "Друг удален",
|
||||
"request_accepted": "Запрос принят",
|
||||
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
||||
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
||||
"blocked_users": "Заблокированные пользователи",
|
||||
"unblock": "Разблокировать",
|
||||
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
||||
"view_all": "Показать все",
|
||||
"load_more": "Загрузить еще",
|
||||
"loading": "Загрузка",
|
||||
"pending": "Ожидание",
|
||||
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
||||
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
||||
@@ -690,6 +701,7 @@
|
||||
"report_reason_other": "Другое",
|
||||
"profile_reported": "Жалоба на профиль отправлена",
|
||||
"your_friend_code": "Код вашего друга:",
|
||||
"copy_friend_code": "Копировать код друга",
|
||||
"upload_banner": "Загрузить баннер",
|
||||
"uploading_banner": "Загрузка баннера...",
|
||||
"background_image_updated": "Фоновое изображение обновлено",
|
||||
@@ -709,9 +721,11 @@
|
||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||
"karma": "Карма",
|
||||
"karma_count": "карма",
|
||||
"karma_description": "Заработана положительными оценками отзывов",
|
||||
"user_reviews": "Отзывы",
|
||||
"loading_reviews": "Загрузка отзывов...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "Просмотреть мой Wrapped 2025",
|
||||
"view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}",
|
||||
"no_reviews": "Пока нет отзывов",
|
||||
"delete_review": "Удалить отзыв"
|
||||
},
|
||||
@@ -764,5 +778,41 @@
|
||||
"all_games": "Все игры",
|
||||
"recently_played": "Недавно сыгранные",
|
||||
"favorites": "Избранное"
|
||||
},
|
||||
"notifications_page": {
|
||||
"title": "Уведомления",
|
||||
"mark_all_as_read": "Отметить все как прочитанные",
|
||||
"clear_all": "Очистить все",
|
||||
"loading": "Загрузка...",
|
||||
"empty_title": "Нет уведомлений",
|
||||
"empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
|
||||
"empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
|
||||
"filter_all": "Все",
|
||||
"filter_unread": "Непрочитанные",
|
||||
"filter_friends": "Друзья",
|
||||
"filter_badges": "Значки",
|
||||
"filter_upvotes": "Голоса",
|
||||
"filter_local": "Локальные",
|
||||
"load_more": "Загрузить еще",
|
||||
"dismiss": "Отклонить",
|
||||
"accept": "Принять",
|
||||
"refuse": "Отклонить",
|
||||
"notification": "Уведомление",
|
||||
"friend_request_received_title": "Новый запрос в друзья!",
|
||||
"friend_request_received_description": "{{displayName}} хочет добавить вас в друзья",
|
||||
"friend_request_accepted_title": "Запрос в друзья принят!",
|
||||
"friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья",
|
||||
"badge_received_title": "Вы получили новый значок!",
|
||||
"badge_received_description": "{{badgeName}}",
|
||||
"review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!",
|
||||
"review_upvote_description": "Ваш отзыв получил {{count}} новых голосов",
|
||||
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
|
||||
"failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные",
|
||||
"cleared_all": "Все уведомления очищены",
|
||||
"failed_to_clear": "Не удалось очистить уведомления",
|
||||
"failed_to_load": "Не удалось загрузить уведомления",
|
||||
"failed_to_dismiss": "Не удалось отклонить уведомление",
|
||||
"friend_request_accepted": "Запрос в друзья принят",
|
||||
"friend_request_refused": "Запрос в друзья отклонен"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,7 +706,6 @@
|
||||
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
|
||||
"user_reviews": "İncelemeler",
|
||||
"delete_review": "İncelemeyi Sil",
|
||||
"loading_reviews": "İncelemeler yükleniyor..."
|
||||
|
||||
@@ -668,8 +668,7 @@
|
||||
"game_removed_from_pinned": "Гру видалено із закріплених",
|
||||
"game_added_to_pinned": "Гру додано до закріплених",
|
||||
"karma": "Карма",
|
||||
"karma_count": "карма",
|
||||
"karma_description": "Зароблена позитивними оцінками на відгуках"
|
||||
"karma_count": "карма"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Досягнення розблоковано",
|
||||
|
||||
@@ -689,7 +689,6 @@
|
||||
"game_removed_from_pinned": "游戏已从置顶移除",
|
||||
"karma": "业力",
|
||||
"karma_count": "业力值",
|
||||
"karma_description": "通过评论获得的点赞",
|
||||
"loading_reviews": "正在加载评价...",
|
||||
"manual_playtime_tooltip": "该游戏时长已手动更新",
|
||||
"pinned": "已置顶",
|
||||
|
||||
23
src/main/events/library/delete-archive.ts
Normal file
23
src/main/events/library/delete-archive.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
const deleteArchive = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
) => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
logger.info(`Deleted archive: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete archive: ${filePath}`, err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("deleteArchive", deleteArchive);
|
||||
@@ -22,6 +22,7 @@ const extractGameDownload = async (
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download,
|
||||
extracting: true,
|
||||
extractionProgress: 0,
|
||||
});
|
||||
|
||||
const gameFilesManager = new GameFilesManager(shop, objectId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import "./close-game";
|
||||
import "./copy-custom-game-asset";
|
||||
import "./create-game-shortcut";
|
||||
import "./create-steam-shortcut";
|
||||
import "./delete-archive";
|
||||
import "./delete-game-folder";
|
||||
import "./extract-game-download";
|
||||
import "./get-default-wine-prefix-selection-path";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const clearAllLocalNotifications = async () => {
|
||||
await LocalNotificationManager.clearAll();
|
||||
};
|
||||
|
||||
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);
|
||||
11
src/main/events/notifications/delete-local-notification.ts
Normal file
11
src/main/events/notifications/delete-local-notification.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const deleteLocalNotification = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: string
|
||||
) => {
|
||||
await LocalNotificationManager.deleteNotification(id);
|
||||
};
|
||||
|
||||
registerEvent("deleteLocalNotification", deleteLocalNotification);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotificationsCount = async () => {
|
||||
return LocalNotificationManager.getUnreadCount();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);
|
||||
8
src/main/events/notifications/get-local-notifications.ts
Normal file
8
src/main/events/notifications/get-local-notifications.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotifications = async () => {
|
||||
return LocalNotificationManager.getNotifications();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotifications", getLocalNotifications);
|
||||
@@ -1,3 +1,9 @@
|
||||
import "./publish-new-repacks-notification";
|
||||
import "./show-achievement-test-notification";
|
||||
import "./update-achievement-notification-window";
|
||||
import "./get-local-notifications";
|
||||
import "./get-local-notifications-count";
|
||||
import "./mark-local-notification-read";
|
||||
import "./mark-all-local-notifications-read";
|
||||
import "./delete-local-notification";
|
||||
import "./clear-all-local-notifications";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const markAllLocalNotificationsRead = async () => {
|
||||
await LocalNotificationManager.markAllAsRead();
|
||||
};
|
||||
|
||||
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const markLocalNotificationRead = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: string
|
||||
) => {
|
||||
await LocalNotificationManager.markAsRead(id);
|
||||
};
|
||||
|
||||
registerEvent("markLocalNotificationRead", markLocalNotificationRead);
|
||||
@@ -1,4 +1,3 @@
|
||||
import "./get-me";
|
||||
import "./process-profile-image";
|
||||
import "./sync-friend-requests";
|
||||
import "./update-profile";
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi, WindowManager } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import type { FriendRequestSync } from "@types";
|
||||
|
||||
export const syncFriendRequests = async () => {
|
||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
|
||||
.then((res) => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-sync-friend-requests",
|
||||
res
|
||||
);
|
||||
|
||||
return res;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return { friendRequestCount: 0 } as FriendRequestSync;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("syncFriendRequests", syncFriendRequests);
|
||||
@@ -13,7 +13,11 @@ const resumeGameDownload = async (
|
||||
|
||||
const download = await downloadsSublevel.get(gameKey);
|
||||
|
||||
if (download?.status === "paused") {
|
||||
if (
|
||||
download &&
|
||||
(download.status === "paused" || download.status === "active") &&
|
||||
download.progress !== 1
|
||||
) {
|
||||
await DownloadManager.pauseDownload();
|
||||
|
||||
for await (const [key, value] of downloadsSublevel.iterator()) {
|
||||
|
||||
@@ -41,7 +41,6 @@ const startGameDownload = async (
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
|
||||
/* Delete any previous download */
|
||||
await downloadsSublevel.del(gameKey);
|
||||
|
||||
if (game) {
|
||||
@@ -82,6 +81,7 @@ const startGameDownload = async (
|
||||
queued: true,
|
||||
extracting: false,
|
||||
automaticallyExtract,
|
||||
extractionProgress: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -123,6 +123,42 @@ const startGameDownload = async (
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (downloader === Downloader.Buzzheavier) {
|
||||
if (err.message.includes("Rate limit")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Buzzheavier: Rate limit exceeded",
|
||||
};
|
||||
}
|
||||
if (
|
||||
err.message.includes("not found") ||
|
||||
err.message.includes("deleted")
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Buzzheavier: File not found",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (downloader === Downloader.FuckingFast) {
|
||||
if (err.message.includes("Rate limit")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "FuckingFast: Rate limit exceeded",
|
||||
};
|
||||
}
|
||||
if (
|
||||
err.message.includes("not found") ||
|
||||
err.message.includes("deleted")
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "FuckingFast: File not found",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @generated by protobuf-ts 2.10.0
|
||||
// @generated by protobuf-ts 2.11.1
|
||||
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||
// tslint:disable
|
||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
|
||||
*/
|
||||
export interface FriendRequest {
|
||||
/**
|
||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
||||
* @generated from protobuf field: int32 friend_request_count = 1
|
||||
*/
|
||||
friendRequestCount: number;
|
||||
/**
|
||||
* @generated from protobuf field: optional string sender_id = 2;
|
||||
* @generated from protobuf field: optional string sender_id = 2
|
||||
*/
|
||||
senderId?: string;
|
||||
}
|
||||
@@ -28,18 +28,27 @@ export interface FriendRequest {
|
||||
*/
|
||||
export interface FriendGameSession {
|
||||
/**
|
||||
* @generated from protobuf field: string object_id = 1;
|
||||
* @generated from protobuf field: string object_id = 1
|
||||
*/
|
||||
objectId: string;
|
||||
/**
|
||||
* @generated from protobuf field: string shop = 2;
|
||||
* @generated from protobuf field: string shop = 2
|
||||
*/
|
||||
shop: string;
|
||||
/**
|
||||
* @generated from protobuf field: string friend_id = 3;
|
||||
* @generated from protobuf field: string friend_id = 3
|
||||
*/
|
||||
friendId: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Notification
|
||||
*/
|
||||
export interface Notification {
|
||||
/**
|
||||
* @generated from protobuf field: int32 notification_count = 1
|
||||
*/
|
||||
notificationCount: number;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Envelope
|
||||
*/
|
||||
@@ -51,17 +60,24 @@ export interface Envelope {
|
||||
| {
|
||||
oneofKind: "friendRequest";
|
||||
/**
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1
|
||||
*/
|
||||
friendRequest: FriendRequest;
|
||||
}
|
||||
| {
|
||||
oneofKind: "friendGameSession";
|
||||
/**
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2
|
||||
*/
|
||||
friendGameSession: FriendGameSession;
|
||||
}
|
||||
| {
|
||||
oneofKind: "notification";
|
||||
/**
|
||||
* @generated from protobuf field: Notification notification = 3
|
||||
*/
|
||||
notification: Notification;
|
||||
}
|
||||
| {
|
||||
oneofKind: undefined;
|
||||
};
|
||||
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
||||
*/
|
||||
export const FriendGameSession = new FriendGameSession$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Notification$Type extends MessageType<Notification> {
|
||||
constructor() {
|
||||
super("Notification", [
|
||||
{
|
||||
no: 1,
|
||||
name: "notification_count",
|
||||
kind: "scalar",
|
||||
T: 5 /*ScalarType.INT32*/,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Notification>): Notification {
|
||||
const message = globalThis.Object.create(this.messagePrototype!);
|
||||
message.notificationCount = 0;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<Notification>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(
|
||||
reader: IBinaryReader,
|
||||
length: number,
|
||||
options: BinaryReadOptions,
|
||||
target?: Notification
|
||||
): Notification {
|
||||
let message = target ?? this.create(),
|
||||
end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* int32 notification_count */ 1:
|
||||
message.notificationCount = reader.int32();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(
|
||||
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||
);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
fieldNo,
|
||||
wireType,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(
|
||||
message: Notification,
|
||||
writer: IBinaryWriter,
|
||||
options: BinaryWriteOptions
|
||||
): IBinaryWriter {
|
||||
/* int32 notification_count = 1; */
|
||||
if (message.notificationCount !== 0)
|
||||
writer.tag(1, WireType.Varint).int32(message.notificationCount);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
writer
|
||||
);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message Notification
|
||||
*/
|
||||
export const Notification = new Notification$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Envelope$Type extends MessageType<Envelope> {
|
||||
constructor() {
|
||||
super("Envelope", [
|
||||
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
oneof: "payload",
|
||||
T: () => FriendGameSession,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
name: "notification",
|
||||
kind: "message",
|
||||
oneof: "payload",
|
||||
T: () => Notification,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Envelope>): Envelope {
|
||||
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
),
|
||||
};
|
||||
break;
|
||||
case /* Notification notification */ 3:
|
||||
message.payload = {
|
||||
oneofKind: "notification",
|
||||
notification: Notification.internalBinaryRead(
|
||||
reader,
|
||||
reader.uint32(),
|
||||
options,
|
||||
(message.payload as any).notification
|
||||
),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
/* Notification notification = 3; */
|
||||
if (message.payload.oneofKind === "notification")
|
||||
Notification.internalBinaryWrite(
|
||||
message.payload.notification,
|
||||
writer.tag(3, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
export * from "./local-notifications";
|
||||
|
||||
@@ -20,4 +20,5 @@ export const levelKeys = {
|
||||
downloadSources: "downloadSources",
|
||||
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
||||
localNotifications: "localNotifications",
|
||||
};
|
||||
|
||||
11
src/main/level/sublevels/local-notifications.ts
Normal file
11
src/main/level/sublevels/local-notifications.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LocalNotification } from "@types";
|
||||
|
||||
import { db } from "../level";
|
||||
import { levelKeys } from "./keys";
|
||||
|
||||
export const localNotificationsSublevel = db.sublevel<
|
||||
string,
|
||||
LocalNotification
|
||||
>(levelKeys.localNotifications, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { Downloader } from "@shared";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
@@ -33,9 +33,7 @@ export const loadState = async () => {
|
||||
|
||||
await import("./events");
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
Aria2.spawn();
|
||||
}
|
||||
Aria2.spawn();
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
@@ -59,8 +57,10 @@ export const loadState = async () => {
|
||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||
void syncDownloadSourcesFromApi();
|
||||
|
||||
// Check for new download options on startup
|
||||
DownloadSourcesChecker.checkForChanges();
|
||||
// Check for new download options on startup (if enabled)
|
||||
(async () => {
|
||||
await DownloadSourcesChecker.checkForChanges();
|
||||
})();
|
||||
WSClient.connect();
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ export const loadState = async () => {
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return sortBy(games, "timestamp", "DESC");
|
||||
return orderBy(games, "timestamp", "desc");
|
||||
});
|
||||
|
||||
downloads.forEach((download) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app } from "electron";
|
||||
import cp from "node:child_process";
|
||||
import Seven, { CommandLineSwitches } from "node-7z";
|
||||
import path from "node:path";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@@ -9,6 +9,17 @@ export const binaryName = {
|
||||
win32: "7z.exe",
|
||||
};
|
||||
|
||||
export interface ExtractionProgress {
|
||||
percent: number;
|
||||
fileCount: number;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
success: boolean;
|
||||
extractedFiles: string[];
|
||||
}
|
||||
|
||||
export class SevenZip {
|
||||
private static readonly binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, binaryName[process.platform])
|
||||
@@ -32,43 +43,109 @@ export class SevenZip {
|
||||
cwd?: string;
|
||||
passwords?: string[];
|
||||
},
|
||||
successCb: () => void,
|
||||
errorCb: () => void
|
||||
) {
|
||||
const tryPassword = (index = -1) => {
|
||||
const password = passwords[index] ?? "";
|
||||
logger.info(`Trying password ${password} on ${filePath}`);
|
||||
onProgress?: (progress: ExtractionProgress) => void
|
||||
): Promise<ExtractionResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryPassword = (index = -1) => {
|
||||
const password = passwords[index] ?? "";
|
||||
logger.info(
|
||||
`Trying password "${password || "(empty)"}" on ${filePath}`
|
||||
);
|
||||
|
||||
const args = ["x", filePath, "-y", "-p" + password];
|
||||
const extractedFiles: string[] = [];
|
||||
let fileCount = 0;
|
||||
|
||||
if (outputPath) {
|
||||
args.push("-o" + outputPath);
|
||||
}
|
||||
const options: CommandLineSwitches = {
|
||||
$bin: this.binaryPath,
|
||||
$progress: true,
|
||||
yes: true,
|
||||
password: password || undefined,
|
||||
};
|
||||
|
||||
const child = cp.execFile(this.binaryPath, args, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
successCb();
|
||||
return;
|
||||
if (outputPath) {
|
||||
options.outputDir = outputPath;
|
||||
}
|
||||
|
||||
if (index < passwords.length - 1) {
|
||||
const stream = Seven.extractFull(filePath, outputPath || cwd || ".", {
|
||||
...options,
|
||||
$spawnOptions: cwd ? { cwd } : undefined,
|
||||
});
|
||||
|
||||
stream.on("progress", (progress) => {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
percent: progress.percent,
|
||||
fileCount: fileCount,
|
||||
file: progress.fileCount?.toString() || "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data) => {
|
||||
if (data.file) {
|
||||
extractedFiles.push(data.file);
|
||||
fileCount++;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
logger.info(
|
||||
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
|
||||
`Successfully extracted ${filePath} (${extractedFiles.length} files)`
|
||||
);
|
||||
resolve({
|
||||
success: true,
|
||||
extractedFiles,
|
||||
});
|
||||
});
|
||||
|
||||
tryPassword(index + 1);
|
||||
} else {
|
||||
logger.info(`Failed to extract file: ${filePath}`);
|
||||
stream.on("error", (err) => {
|
||||
logger.error(`Extraction error for ${filePath}:`, err);
|
||||
|
||||
errorCb();
|
||||
if (index < passwords.length - 1) {
|
||||
logger.info(
|
||||
`Failed to extract file: ${filePath} with password: "${password}". Trying next password...`
|
||||
);
|
||||
tryPassword(index + 1);
|
||||
} else {
|
||||
logger.error(
|
||||
`Failed to extract file: ${filePath} after trying all passwords`
|
||||
);
|
||||
reject(new Error(`Failed to extract file: ${filePath}`));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryPassword();
|
||||
});
|
||||
}
|
||||
|
||||
public static listFiles(
|
||||
filePath: string,
|
||||
password?: string
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const files: string[] = [];
|
||||
|
||||
const options: CommandLineSwitches = {
|
||||
$bin: this.binaryPath,
|
||||
password: password || undefined,
|
||||
};
|
||||
|
||||
const stream = Seven.list(filePath, options);
|
||||
|
||||
stream.on("data", (data) => {
|
||||
if (data.file) {
|
||||
files.push(data.file);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryPassword();
|
||||
stream.on("end", () => {
|
||||
resolve(files);
|
||||
});
|
||||
|
||||
stream.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
|
||||
public static spawn() {
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2c")
|
||||
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
||||
const binaryPath =
|
||||
process.platform === "darwin"
|
||||
? "aria2c"
|
||||
: app.isPackaged
|
||||
? path.join(process.resourcesPath, "aria2c")
|
||||
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
||||
|
||||
this.process = cp.spawn(
|
||||
binaryPath,
|
||||
|
||||
@@ -74,21 +74,16 @@ export class DeckyPlugin {
|
||||
|
||||
await fs.promises.mkdir(extractPath, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: zipPath,
|
||||
outputPath: extractPath,
|
||||
},
|
||||
() => {
|
||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||
resolve(extractPath);
|
||||
},
|
||||
() => {
|
||||
reject(new Error("Failed to extract plugin"));
|
||||
}
|
||||
);
|
||||
});
|
||||
try {
|
||||
await SevenZip.extractFile({
|
||||
filePath: zipPath,
|
||||
outputPath: extractPath,
|
||||
});
|
||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||
return extractPath;
|
||||
} catch {
|
||||
throw new Error("Failed to extract plugin");
|
||||
}
|
||||
}
|
||||
|
||||
private static needsSudo(): boolean {
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
updateDownloadSourcesCheckBaseline,
|
||||
updateDownloadSourcesSinceValue,
|
||||
downloadSourcesSublevel,
|
||||
db,
|
||||
levelKeys,
|
||||
} from "@main/level";
|
||||
import { logger } from "./logger";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import type { Game } from "@types";
|
||||
import type { Game, UserPreferences } from "@types";
|
||||
|
||||
interface DownloadSourcesChangeResponse {
|
||||
shop: string;
|
||||
@@ -101,6 +103,20 @@ export class DownloadSourcesChecker {
|
||||
logger.info("DownloadSourcesChecker.checkForChanges() called");
|
||||
|
||||
try {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
|
||||
if (userPreferences?.enableNewDownloadOptionsBadges === false) {
|
||||
logger.info(
|
||||
"New download options badges are disabled, skipping download sources check"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all installed games (excluding custom games)
|
||||
const installedGames = await gamesSublevel.values().all();
|
||||
const nonCustomGames = installedGames.filter(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DatanodesApi,
|
||||
MediafireApi,
|
||||
PixelDrainApi,
|
||||
VikingFileApi,
|
||||
} from "../hosters";
|
||||
import { PythonRPC } from "../python-rpc";
|
||||
import {
|
||||
@@ -24,10 +25,80 @@ import { sortBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { GameFilesManager } from "../game-files-manager";
|
||||
import { HydraDebridClient } from "./hydra-debrid";
|
||||
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
|
||||
private static extractFilename(
|
||||
url: string,
|
||||
originalUrl?: string
|
||||
): string | undefined {
|
||||
if (originalUrl?.includes("#")) {
|
||||
const hashPart = originalUrl.split("#")[1];
|
||||
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||
return hashPart;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes("#")) {
|
||||
const hashPart = url.split("#")[1];
|
||||
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||
return hashPart;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const pathParts = pathname.split("/");
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
|
||||
if (filename?.includes(".") && filename.length > 0) {
|
||||
return decodeURIComponent(filename);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static sanitizeFilename(filename: string): string {
|
||||
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
|
||||
}
|
||||
|
||||
private static createDownloadPayload(
|
||||
directUrl: string,
|
||||
originalUrl: string,
|
||||
downloadId: string,
|
||||
savePath: string
|
||||
) {
|
||||
const filename =
|
||||
this.extractFilename(originalUrl, directUrl) ||
|
||||
this.extractFilename(directUrl);
|
||||
const sanitizedFilename = filename
|
||||
? this.sanitizeFilename(filename)
|
||||
: undefined;
|
||||
|
||||
if (sanitizedFilename) {
|
||||
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||
} else {
|
||||
logger.log(
|
||||
`[DownloadManager] No filename extracted, aria2 will use default`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
action: "start" as const,
|
||||
game_id: downloadId,
|
||||
url: directUrl,
|
||||
save_path: savePath,
|
||||
out: sanitizedFilename,
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
downloadsToSeed?: Download[]
|
||||
@@ -80,14 +151,28 @@ export class DownloadManager {
|
||||
if (!isDownloadingMetadata && !isCheckingFiles) {
|
||||
if (!download) return null;
|
||||
|
||||
await downloadsSublevel.put(downloadId, {
|
||||
const updatedDownload = {
|
||||
...download,
|
||||
bytesDownloaded,
|
||||
fileSize,
|
||||
progress,
|
||||
folderName,
|
||||
status: "active",
|
||||
});
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
await downloadsSublevel.put(downloadId, updatedDownload);
|
||||
|
||||
return {
|
||||
numPeers,
|
||||
numSeeds,
|
||||
downloadSpeed,
|
||||
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
|
||||
isDownloadingMetadata,
|
||||
isCheckingFiles,
|
||||
progress,
|
||||
gameId: downloadId,
|
||||
download: updatedDownload,
|
||||
} as DownloadProgress;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -121,21 +206,14 @@ export class DownloadManager {
|
||||
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (WindowManager.mainWindow && download) {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
...status,
|
||||
game,
|
||||
})
|
||||
)
|
||||
JSON.parse(JSON.stringify({ ...status, game }))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,27 +257,25 @@ export class DownloadManager {
|
||||
)
|
||||
) {
|
||||
gameFilesManager.extractDownloadedFile();
|
||||
} else {
|
||||
} else if (download.folderName) {
|
||||
gameFilesManager
|
||||
.extractFilesInDirectory(
|
||||
path.join(download.downloadPath, download.folderName!)
|
||||
path.join(download.downloadPath, download.folderName)
|
||||
)
|
||||
.then(() => {
|
||||
gameFilesManager.setExtractionComplete();
|
||||
});
|
||||
.then(() => gameFilesManager.setExtractionComplete());
|
||||
}
|
||||
}
|
||||
|
||||
const downloads = await downloadsSublevel
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return sortBy(
|
||||
.then((games) =>
|
||||
sortBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
);
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
const [nextItemOnQueue] = downloads;
|
||||
|
||||
@@ -267,13 +343,8 @@ export class DownloadManager {
|
||||
|
||||
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "cancel",
|
||||
game_id: downloadKey,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to cancel game download", err);
|
||||
});
|
||||
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@@ -306,7 +377,6 @@ export class DownloadManager {
|
||||
const id = download.uri.split("/").pop();
|
||||
const token = await GofileApi.authorize();
|
||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||
|
||||
await GofileApi.checkDownloadUrl(downloadLink);
|
||||
|
||||
return {
|
||||
@@ -348,9 +418,50 @@ export class DownloadManager {
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
case Downloader.Buzzheavier: {
|
||||
logger.log(
|
||||
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||
);
|
||||
try {
|
||||
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
||||
return this.createDownloadPayload(
|
||||
directUrl,
|
||||
download.uri,
|
||||
downloadId,
|
||||
download.downloadPath
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[DownloadManager] Error processing Buzzheavier download:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.FuckingFast: {
|
||||
logger.log(
|
||||
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||
);
|
||||
try {
|
||||
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
||||
return this.createDownloadPayload(
|
||||
directUrl,
|
||||
download.uri,
|
||||
downloadId,
|
||||
download.downloadPath
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[DownloadManager] Error processing FuckingFast download:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.Mediafire: {
|
||||
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
@@ -367,7 +478,6 @@ export class DownloadManager {
|
||||
};
|
||||
case Downloader.RealDebrid: {
|
||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||
|
||||
return {
|
||||
@@ -380,7 +490,6 @@ export class DownloadManager {
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||
|
||||
if (!url) return;
|
||||
return {
|
||||
action: "start",
|
||||
@@ -395,7 +504,6 @@ export class DownloadManager {
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||
download.uri
|
||||
);
|
||||
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||
|
||||
return {
|
||||
@@ -406,6 +514,29 @@ export class DownloadManager {
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
case Downloader.VikingFile: {
|
||||
logger.log(
|
||||
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
|
||||
);
|
||||
try {
|
||||
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
|
||||
logger.log(`[DownloadManager] VikingFile direct URL obtained`);
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,58 @@ import fs from "node:fs";
|
||||
import type { GameShop } from "@types";
|
||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
||||
import { SevenZip } from "./7zip";
|
||||
import { SevenZip, ExtractionProgress } from "./7zip";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { publishExtractionCompleteNotification } from "./notifications";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
export class GameFilesManager {
|
||||
private lastProgressUpdate = 0;
|
||||
|
||||
constructor(
|
||||
private readonly shop: GameShop,
|
||||
private readonly objectId: string
|
||||
) {}
|
||||
|
||||
private async clearExtractionState() {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
const download = await downloadsSublevel.get(gameKey);
|
||||
private get gameKey() {
|
||||
return levelKeys.game(this.shop, this.objectId);
|
||||
}
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
private async updateExtractionProgress(progress: number, force = false) {
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastProgressUpdate = now;
|
||||
|
||||
const download = await downloadsSublevel.get(this.gameKey);
|
||||
if (!download) return;
|
||||
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
extractionProgress: progress,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-extraction-progress",
|
||||
this.shop,
|
||||
this.objectId,
|
||||
progress
|
||||
);
|
||||
}
|
||||
|
||||
private async clearExtractionState() {
|
||||
const download = await downloadsSublevel.get(this.gameKey);
|
||||
if (!download) return;
|
||||
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
extracting: false,
|
||||
extractionProgress: 0,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
@@ -30,6 +64,10 @@ export class GameFilesManager {
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleProgress = (progress: ExtractionProgress) => {
|
||||
this.updateExtractionProgress(progress.percent / 100);
|
||||
};
|
||||
|
||||
async extractFilesInDirectory(directoryPath: string) {
|
||||
if (!fs.existsSync(directoryPath)) return;
|
||||
const files = await fs.promises.readdir(directoryPath);
|
||||
@@ -42,53 +80,66 @@ export class GameFilesManager {
|
||||
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
filesToExtract.map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: path.join(directoryPath, file),
|
||||
cwd: directoryPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
() => {
|
||||
resolve(true);
|
||||
},
|
||||
() => {
|
||||
reject(new Error(`Failed to extract file: ${file}`));
|
||||
this.clearExtractionState();
|
||||
}
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
if (filesToExtract.length === 0) return;
|
||||
|
||||
compressedFiles.forEach((file) => {
|
||||
const extractionPath = path.join(directoryPath, file);
|
||||
await this.updateExtractionProgress(0, true);
|
||||
|
||||
if (fs.existsSync(extractionPath)) {
|
||||
fs.unlink(extractionPath, (err) => {
|
||||
if (err) {
|
||||
logger.error(`Failed to delete file: ${file}`, err);
|
||||
const totalFiles = filesToExtract.length;
|
||||
let completedFiles = 0;
|
||||
|
||||
this.clearExtractionState();
|
||||
for (const file of filesToExtract) {
|
||||
try {
|
||||
const result = await SevenZip.extractFile(
|
||||
{
|
||||
filePath: path.join(directoryPath, file),
|
||||
cwd: directoryPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
(progress) => {
|
||||
const overallProgress =
|
||||
(completedFiles + progress.percent / 100) / totalFiles;
|
||||
this.updateExtractionProgress(overallProgress);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
completedFiles++;
|
||||
await this.updateExtractionProgress(
|
||||
completedFiles / totalFiles,
|
||||
true
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to extract file: ${file}`, err);
|
||||
await this.clearExtractionState();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const archivePaths = compressedFiles
|
||||
.map((file) => path.join(directoryPath, file))
|
||||
.filter((archivePath) => fs.existsSync(archivePath));
|
||||
|
||||
if (archivePaths.length > 0) {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-archive-deletion-prompt",
|
||||
archivePaths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setExtractionComplete(publishNotification = true) {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
if (!download) return;
|
||||
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
extracting: false,
|
||||
extractionProgress: 0,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
@@ -97,17 +148,15 @@ export class GameFilesManager {
|
||||
this.objectId
|
||||
);
|
||||
|
||||
if (publishNotification) {
|
||||
publishExtractionCompleteNotification(game!);
|
||||
if (publishNotification && game) {
|
||||
publishExtractionCompleteNotification(game);
|
||||
}
|
||||
}
|
||||
|
||||
async extractDownloadedFile() {
|
||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
||||
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(gameKey),
|
||||
gamesSublevel.get(gameKey),
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game) return false;
|
||||
@@ -119,39 +168,39 @@ export class GameFilesManager {
|
||||
path.parse(download.folderName!).name
|
||||
);
|
||||
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath,
|
||||
outputPath: extractionPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
async () => {
|
||||
await this.updateExtractionProgress(0, true);
|
||||
|
||||
try {
|
||||
const result = await SevenZip.extractFile(
|
||||
{
|
||||
filePath,
|
||||
outputPath: extractionPath,
|
||||
passwords: ["online-fix.me", "steamrip.com"],
|
||||
},
|
||||
this.handleProgress
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
await this.extractFilesInDirectory(extractionPath);
|
||||
|
||||
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
logger.error(
|
||||
`Failed to delete file: ${download.folderName}`,
|
||||
err
|
||||
);
|
||||
|
||||
this.clearExtractionState();
|
||||
}
|
||||
});
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-archive-deletion-prompt",
|
||||
[filePath]
|
||||
);
|
||||
}
|
||||
|
||||
await downloadsSublevel.put(gameKey, {
|
||||
...download!,
|
||||
await downloadsSublevel.put(this.gameKey, {
|
||||
...download,
|
||||
folderName: path.parse(download.folderName!).name,
|
||||
});
|
||||
|
||||
this.setExtractionComplete();
|
||||
},
|
||||
() => {
|
||||
this.clearExtractionState();
|
||||
await this.setExtractionComplete();
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
|
||||
await this.clearExtractionState();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
100
src/main/services/hosters/buzzheavier.ts
Normal file
100
src/main/services/hosters/buzzheavier.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import axios from "axios";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import {
|
||||
HOSTER_USER_AGENT,
|
||||
extractHosterFilename,
|
||||
handleHosterError,
|
||||
} from "./fuckingfast";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
export class BuzzheavierApi {
|
||||
private static readonly BUZZHEAVIER_DOMAINS = [
|
||||
"buzzheavier.com",
|
||||
"bzzhr.co",
|
||||
"fuckingfast.net",
|
||||
];
|
||||
|
||||
private static isSupportedDomain(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||
}
|
||||
|
||||
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = url.split("#")[0];
|
||||
logger.log(
|
||||
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
|
||||
);
|
||||
|
||||
await axios.get(baseUrl, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
timeout: 30000,
|
||||
httpAgent: new http.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
httpsAgent: new https.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
});
|
||||
|
||||
const downloadUrl = `${baseUrl}/download`;
|
||||
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
|
||||
const headResponse = await axios.head(downloadUrl, {
|
||||
headers: {
|
||||
"hx-current-url": baseUrl,
|
||||
"hx-request": "true",
|
||||
referer: baseUrl,
|
||||
"User-Agent": HOSTER_USER_AGENT,
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) =>
|
||||
status === 200 || status === 204 || status === 301 || status === 302,
|
||||
timeout: 30000,
|
||||
httpAgent: new http.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
httpsAgent: new https.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
});
|
||||
|
||||
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||
if (!hxRedirect) {
|
||||
logger.error(
|
||||
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
|
||||
);
|
||||
throw new Error(
|
||||
"Could not extract download link. File may be deleted or is a directory."
|
||||
);
|
||||
}
|
||||
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
const directLink = hxRedirect.startsWith("/dl/")
|
||||
? `https://${domain}${hxRedirect}`
|
||||
: hxRedirect;
|
||||
logger.log(`[Buzzheavier] Extracted direct link`);
|
||||
return directLink;
|
||||
} catch (error) {
|
||||
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
|
||||
handleHosterError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDirectLink(url: string): Promise<string> {
|
||||
if (!this.isSupportedDomain(url)) {
|
||||
throw new Error(
|
||||
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
|
||||
);
|
||||
}
|
||||
return this.getBuzzheavierDirectLink(url);
|
||||
}
|
||||
|
||||
public static async getFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
return extractHosterFilename(url, directUrl);
|
||||
}
|
||||
}
|
||||
129
src/main/services/hosters/fuckingfast.ts
Normal file
129
src/main/services/hosters/fuckingfast.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import axios from "axios";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
export const HOSTER_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
|
||||
|
||||
export async function extractHosterFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
if (url.includes("#")) {
|
||||
const fragment = url.split("#")[1];
|
||||
if (fragment && !fragment.startsWith("http")) {
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
if (directUrl) {
|
||||
try {
|
||||
const response = await axios.head(directUrl, {
|
||||
timeout: 10000,
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers["content-disposition"];
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
|
||||
contentDisposition
|
||||
);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
return filenameMatch[1].replace(/['"]/g, "");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const urlPath = new URL(directUrl).pathname;
|
||||
const filename = urlPath.split("/").pop()?.split("?")[0];
|
||||
if (filename) {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
return "downloaded_file";
|
||||
}
|
||||
|
||||
export function handleHosterError(error: unknown): never {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Rate limit exceeded. Please try again later.");
|
||||
}
|
||||
if (error.response?.status === 403) {
|
||||
throw new Error("Access denied. File may be private or deleted.");
|
||||
}
|
||||
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FuckingFast API Class
|
||||
// ============================================
|
||||
export class FuckingFastApi {
|
||||
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
|
||||
|
||||
private static readonly FUCKINGFAST_REGEX =
|
||||
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
|
||||
|
||||
private static isSupportedDomain(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||
}
|
||||
|
||||
private static async getFuckingFastDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
|
||||
const response = await axios.get(url, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
|
||||
if (html.toLowerCase().includes("rate limit")) {
|
||||
logger.error(`[FuckingFast] Rate limit detected`);
|
||||
throw new Error(
|
||||
"Rate limit exceeded. Please wait a few minutes and try again."
|
||||
);
|
||||
}
|
||||
|
||||
if (html.includes("File Not Found Or Deleted")) {
|
||||
logger.error(`[FuckingFast] File not found or deleted`);
|
||||
throw new Error("File not found or deleted");
|
||||
}
|
||||
|
||||
const match = this.FUCKINGFAST_REGEX.exec(html);
|
||||
if (!match || !match[1]) {
|
||||
logger.error(`[FuckingFast] Could not extract download link`);
|
||||
throw new Error("Could not extract download link from page");
|
||||
}
|
||||
|
||||
logger.log(`[FuckingFast] Successfully extracted direct link`);
|
||||
return match[1];
|
||||
} catch (error) {
|
||||
logger.error(`[FuckingFast] Error:`, error);
|
||||
handleHosterError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDirectLink(url: string): Promise<string> {
|
||||
if (!this.isSupportedDomain(url)) {
|
||||
throw new Error(
|
||||
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
|
||||
);
|
||||
}
|
||||
return this.getFuckingFastDirectLink(url);
|
||||
}
|
||||
|
||||
public static async getFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
return extractHosterFilename(url, directUrl);
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,13 @@ export class GofileApi {
|
||||
}
|
||||
|
||||
public static async getDownloadLink(id: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
wt: WT,
|
||||
});
|
||||
|
||||
const response = await axios.get<{
|
||||
status: string;
|
||||
data: GofileContentsResponse;
|
||||
}>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
|
||||
}>(`https://api.gofile.io/contents/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"X-Website-Token": WT,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ export * from "./qiwi";
|
||||
export * from "./datanodes";
|
||||
export * from "./mediafire";
|
||||
export * from "./pixeldrain";
|
||||
export * from "./buzzheavier";
|
||||
export * from "./fuckingfast";
|
||||
export * from "./vikingfile";
|
||||
|
||||
46
src/main/services/hosters/vikingfile.ts
Normal file
46
src/main/services/hosters/vikingfile.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from "axios";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface UnlockResponse {
|
||||
link: string;
|
||||
hoster: string;
|
||||
}
|
||||
|
||||
export class VikingFileApi {
|
||||
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||
const unlockResponse = await axios.post<UnlockResponse>(
|
||||
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
|
||||
{ url: uri }
|
||||
);
|
||||
|
||||
if (!unlockResponse.data.link) {
|
||||
throw new Error("Failed to unlock VikingFile URL");
|
||||
}
|
||||
|
||||
const redirectUrl = unlockResponse.data.link;
|
||||
|
||||
try {
|
||||
const redirectResponse = await axios.head(redirectUrl, {
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) =>
|
||||
status === 301 || status === 302 || status === 200,
|
||||
});
|
||||
|
||||
if (
|
||||
redirectResponse.headers.location ||
|
||||
redirectResponse.status === 301 ||
|
||||
redirectResponse.status === 302
|
||||
) {
|
||||
return redirectResponse.headers.location || redirectUrl;
|
||||
}
|
||||
|
||||
return redirectUrl;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[VikingFile] Error following redirect, using redirect URL:`,
|
||||
error
|
||||
);
|
||||
return redirectUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
|
||||
private static secondsToMilliseconds(seconds: number) {
|
||||
return seconds * 1000;
|
||||
@@ -58,7 +58,13 @@ export class HydraApi {
|
||||
const decodedBase64 = atob(payload as string);
|
||||
const jsonData = JSON.parse(decodedBase64);
|
||||
|
||||
const { accessToken, expiresIn, refreshToken } = jsonData;
|
||||
const {
|
||||
accessToken,
|
||||
expiresIn,
|
||||
refreshToken,
|
||||
featurebaseJwt,
|
||||
workwondersJwt,
|
||||
} = jsonData;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -85,6 +91,8 @@ export class HydraApi {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpirationTimestamp,
|
||||
featurebaseJwt,
|
||||
workwondersJwt,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
export * from "./notifications/local-notifications";
|
||||
|
||||
87
src/main/services/node-7z.d.ts
vendored
Normal file
87
src/main/services/node-7z.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
declare module "node-7z" {
|
||||
import { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export interface CommandLineSwitches {
|
||||
$bin?: string;
|
||||
$progress?: boolean;
|
||||
$spawnOptions?: {
|
||||
cwd?: string;
|
||||
};
|
||||
outputDir?: string;
|
||||
yes?: boolean;
|
||||
password?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProgressInfo {
|
||||
percent: number;
|
||||
fileCount?: number;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
file?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ZipStream extends EventEmitter {
|
||||
on(event: "progress", listener: (progress: ProgressInfo) => void): this;
|
||||
on(event: "data", listener: (data: FileInfo) => void): this;
|
||||
on(event: "end", listener: () => void): this;
|
||||
on(event: "error", listener: (err: Error) => void): this;
|
||||
info: Map<string, unknown>;
|
||||
_childProcess?: ChildProcess;
|
||||
}
|
||||
|
||||
export function extractFull(
|
||||
archive: string,
|
||||
output: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function extract(
|
||||
archive: string,
|
||||
output: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function list(
|
||||
archive: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function add(
|
||||
archive: string,
|
||||
files: string | string[],
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function update(
|
||||
archive: string,
|
||||
files: string | string[],
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function deleteFiles(
|
||||
archive: string,
|
||||
files: string | string[],
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
export function test(
|
||||
archive: string,
|
||||
options?: CommandLineSwitches
|
||||
): ZipStream;
|
||||
|
||||
const Seven: {
|
||||
extractFull: typeof extractFull;
|
||||
extract: typeof extract;
|
||||
list: typeof list;
|
||||
add: typeof add;
|
||||
update: typeof update;
|
||||
delete: typeof deleteFiles;
|
||||
test: typeof test;
|
||||
};
|
||||
|
||||
export default Seven;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
|
||||
import { SystemPath } from "../system-path";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||
import { LocalNotificationManager } from "./local-notifications";
|
||||
|
||||
const getStaticImage = async (path: string) => {
|
||||
return processProfileImage(path, "jpg")
|
||||
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
}
|
||||
);
|
||||
|
||||
const title = t("download_complete", { ns: "notifications" });
|
||||
const body = t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
});
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: await downloadImage(game.iconUrl),
|
||||
}).show();
|
||||
}
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"DOWNLOAD_COMPLETE",
|
||||
title,
|
||||
body,
|
||||
{
|
||||
pictureUrl: game.iconUrl,
|
||||
url: `/game/${game.shop}/${game.objectId}`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNotificationUpdateReadyToInstall = async (
|
||||
version: string
|
||||
) => {
|
||||
const title = t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
});
|
||||
const body = t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
});
|
||||
|
||||
new Notification({
|
||||
title: t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
}),
|
||||
body: t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: trayIcon,
|
||||
})
|
||||
.on("click", () => {
|
||||
restartAndInstallUpdate();
|
||||
})
|
||||
.show();
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"UPDATE_AVAILABLE",
|
||||
title,
|
||||
body
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNewFriendRequestNotification = async (
|
||||
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
|
||||
};
|
||||
|
||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||
const title = t("extraction_complete", { ns: "notifications" });
|
||||
const body = t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
});
|
||||
|
||||
new Notification({
|
||||
title: t("extraction_complete", { ns: "notifications" }),
|
||||
body: t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"EXTRACTION_COMPLETE",
|
||||
title,
|
||||
body,
|
||||
{
|
||||
url: `/game/${game.shop}/${game.objectId}`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNewAchievementNotification = async (info: {
|
||||
|
||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { localNotificationsSublevel } from "@main/level";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { LocalNotification, LocalNotificationType } from "@types";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export class LocalNotificationManager {
|
||||
private static generateId(): string {
|
||||
return crypto.randomBytes(8).toString("hex");
|
||||
}
|
||||
|
||||
static async createNotification(
|
||||
type: LocalNotificationType,
|
||||
title: string,
|
||||
description: string,
|
||||
options?: {
|
||||
pictureUrl?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
): Promise<LocalNotification> {
|
||||
const id = this.generateId();
|
||||
const notification: LocalNotification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
pictureUrl: options?.pictureUrl ?? null,
|
||||
url: options?.url ?? null,
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
|
||||
// Notify renderer about new notification
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-local-notification-created",
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
static async getNotifications(): Promise<LocalNotification[]> {
|
||||
const notifications: LocalNotification[] = [];
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
notifications.push(value);
|
||||
}
|
||||
|
||||
// Sort by createdAt descending
|
||||
return notifications.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
static async getUnreadCount(): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static async markAsRead(id: string): Promise<void> {
|
||||
const notification = await localNotificationsSublevel.get(id);
|
||||
if (notification) {
|
||||
notification.isRead = true;
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
}
|
||||
}
|
||||
|
||||
static async markAllAsRead(): Promise<void> {
|
||||
const batch = localNotificationsSublevel.batch();
|
||||
|
||||
for await (const [key, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
value.isRead = true;
|
||||
batch.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.write();
|
||||
}
|
||||
|
||||
static async deleteNotification(id: string): Promise<void> {
|
||||
await localNotificationsSublevel.del(id);
|
||||
}
|
||||
|
||||
static async clearAll(): Promise<void> {
|
||||
await localNotificationsSublevel.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import http from "node:http";
|
||||
|
||||
import cp from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
@@ -31,6 +32,9 @@ export class PythonRPC {
|
||||
public static readonly RPC_PORT = "8084";
|
||||
public static readonly rpc = axios.create({
|
||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||
httpAgent: new http.Agent({
|
||||
family: 4, // Force IPv4
|
||||
}),
|
||||
});
|
||||
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
|
||||
@@ -36,9 +36,9 @@ export class WindowManager {
|
||||
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
||||
{
|
||||
width: 1200,
|
||||
height: 720,
|
||||
height: 860,
|
||||
minWidth: 1024,
|
||||
minHeight: 540,
|
||||
minHeight: 860,
|
||||
backgroundColor: "#1c1c1c",
|
||||
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
||||
icon,
|
||||
@@ -106,7 +106,7 @@ export class WindowManager {
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
return data ?? { isMaximized: false, height: 720, width: 1200 };
|
||||
return data ?? { isMaximized: false, height: 860, width: 1200 };
|
||||
}
|
||||
|
||||
private static updateInitialConfig(
|
||||
@@ -138,7 +138,8 @@ export class WindowManager {
|
||||
(details, callback) => {
|
||||
if (
|
||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||
details.url.includes("chatwoot")
|
||||
details.url.includes("chatwoot") ||
|
||||
details.url.includes("workwonders")
|
||||
) {
|
||||
return callback(details);
|
||||
}
|
||||
@@ -159,7 +160,8 @@ export class WindowManager {
|
||||
if (
|
||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||
details.url.includes("featurebase") ||
|
||||
details.url.includes("chatwoot")
|
||||
details.url.includes("chatwoot") ||
|
||||
details.url.includes("workwonders")
|
||||
) {
|
||||
return callback(details);
|
||||
}
|
||||
@@ -222,7 +224,7 @@ export class WindowManager {
|
||||
? {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
height: this.initialConfigInitializationMainWindow.height ?? 720,
|
||||
height: this.initialConfigInitializationMainWindow.height ?? 860,
|
||||
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
||||
isMaximized: true,
|
||||
}
|
||||
|
||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Notification } from "@main/generated/envelope";
|
||||
import { WindowManager } from "@main/services/window-manager";
|
||||
|
||||
export const notificationEvent = (payload: Notification) => {
|
||||
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
|
||||
notificationCount: payload.notificationCount,
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
|
||||
import { logger } from "../logger";
|
||||
import { friendRequestEvent } from "./events/friend-request";
|
||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||
import { notificationEvent } from "./events/notification";
|
||||
|
||||
export class WSClient {
|
||||
private static ws: WebSocket | null = null;
|
||||
@@ -51,6 +52,10 @@ export class WSClient {
|
||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||
}
|
||||
|
||||
if (envelope.payload.oneofKind === "notification") {
|
||||
notificationEvent(envelope.payload.notification);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||
|
||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
|
||||
readonly MAIN_VITE_WS_URL: string;
|
||||
readonly MAIN_VITE_NIMBUS_API_URL: string;
|
||||
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
|
||||
readonly ELECTRON_RENDERER_URL: string;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
GameAchievement,
|
||||
Theme,
|
||||
FriendRequestSync,
|
||||
NotificationSync,
|
||||
ShortcutLocation,
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
@@ -267,6 +268,29 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.on("on-extraction-complete", listener);
|
||||
return () => ipcRenderer.removeListener("on-extraction-complete", listener);
|
||||
},
|
||||
onExtractionProgress: (
|
||||
cb: (shop: GameShop, objectId: string, progress: number) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
progress: number
|
||||
) => cb(shop, objectId, progress);
|
||||
ipcRenderer.on("on-extraction-progress", listener);
|
||||
return () => ipcRenderer.removeListener("on-extraction-progress", listener);
|
||||
},
|
||||
onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
archivePaths: string[]
|
||||
) => cb(archivePaths);
|
||||
ipcRenderer.on("on-archive-deletion-prompt", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-archive-deletion-prompt", listener);
|
||||
},
|
||||
deleteArchive: (filePath: string) =>
|
||||
ipcRenderer.invoke("deleteArchive", filePath),
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
@@ -474,7 +498,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("updateProfile", updateProfile),
|
||||
processProfileImage: (imagePath: string) =>
|
||||
ipcRenderer.invoke("processProfileImage", imagePath),
|
||||
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
|
||||
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
@@ -484,6 +507,15 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
||||
},
|
||||
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
notification: NotificationSync
|
||||
) => cb(notification);
|
||||
ipcRenderer.on("on-sync-notification-count", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-sync-notification-count", listener);
|
||||
},
|
||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||
|
||||
@@ -527,6 +559,26 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
||||
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
|
||||
getLocalNotificationsCount: () =>
|
||||
ipcRenderer.invoke("getLocalNotificationsCount"),
|
||||
markLocalNotificationRead: (id: string) =>
|
||||
ipcRenderer.invoke("markLocalNotificationRead", id),
|
||||
markAllLocalNotificationsRead: () =>
|
||||
ipcRenderer.invoke("markAllLocalNotificationsRead"),
|
||||
deleteLocalNotification: (id: string) =>
|
||||
ipcRenderer.invoke("deleteLocalNotification", id),
|
||||
clearAllLocalNotifications: () =>
|
||||
ipcRenderer.invoke("clearAllLocalNotifications"),
|
||||
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
notification: unknown
|
||||
) => cb(notification);
|
||||
ipcRenderer.on("on-local-notification-created", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-local-notification-created", listener);
|
||||
},
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
position?: AchievementCustomNotificationPosition,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>Hydra Launcher</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' * data: local:;"
|
||||
content="default-src 'self' 'unsafe-inline' * data: local:; media-src 'self' 'unsafe-inline' * data: local: blob:;"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
import { WorkWondersSdk } from "workwonders-sdk";
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setGameRunning,
|
||||
setExtractionProgress,
|
||||
clearExtraction,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
|
||||
|
||||
import {
|
||||
injectCustomCss,
|
||||
@@ -50,13 +52,10 @@ export function App() {
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const workwondersRef = useRef<WorkWondersSdk | null>(null);
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
hasActiveSubscription,
|
||||
isFriendsModalVisible,
|
||||
friendRequetsModalTab,
|
||||
friendModalUserId,
|
||||
hideFriendsModal,
|
||||
fetchUserDetails,
|
||||
updateUserDetails,
|
||||
clearUserDetails,
|
||||
@@ -78,6 +77,10 @@ export function App() {
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
|
||||
useState(false);
|
||||
const [archivePaths, setArchivePaths] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
levelDBService.get("userPreferences", null, "json"),
|
||||
@@ -113,7 +116,30 @@ export function App() {
|
||||
return () => unsubscribe();
|
||||
}, [updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
const setupWorkWonders = useCallback(
|
||||
async (token?: string, locale?: string) => {
|
||||
if (workwondersRef.current) return;
|
||||
|
||||
const possibleLocales = ["en", "pt", "ru"];
|
||||
|
||||
const parsedLocale =
|
||||
possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en";
|
||||
|
||||
workwondersRef.current = new WorkWondersSdk();
|
||||
await workwondersRef.current.init({
|
||||
organization: "hydra",
|
||||
token,
|
||||
locale: parsedLocale,
|
||||
});
|
||||
|
||||
await workwondersRef.current.initChangelogWidget();
|
||||
workwondersRef.current.initChangelogWidgetMini();
|
||||
workwondersRef.current.initFeedbackWidget();
|
||||
},
|
||||
[workwondersRef]
|
||||
);
|
||||
|
||||
const setupExternalResources = useCallback(async () => {
|
||||
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
||||
|
||||
if (cachedUserDetails) {
|
||||
@@ -124,28 +150,31 @@ export function App() {
|
||||
dispatch(setProfileBackground(profileBackground));
|
||||
}
|
||||
|
||||
fetchUserDetails()
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
window.electron.syncFriendRequests();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (document.getElementById("external-resources")) return;
|
||||
const userPreferences = await window.electron.getUserPreferences();
|
||||
const userDetails = await fetchUserDetails().catch(() => null);
|
||||
|
||||
const $script = document.createElement("script");
|
||||
$script.id = "external-resources";
|
||||
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
||||
document.head.appendChild($script);
|
||||
});
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
if (userDetails) {
|
||||
updateUserDetails(userDetails);
|
||||
}
|
||||
|
||||
setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
|
||||
|
||||
if (!document.getElementById("external-resources")) {
|
||||
const $script = document.createElement("script");
|
||||
$script.id = "external-resources";
|
||||
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
||||
document.head.appendChild($script);
|
||||
}
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
|
||||
|
||||
useEffect(() => {
|
||||
setupExternalResources();
|
||||
}, [setupExternalResources]);
|
||||
|
||||
const onSignIn = useCallback(() => {
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
window.electron.syncFriendRequests();
|
||||
showSuccessToast(t("successfully_signed_in"));
|
||||
}
|
||||
});
|
||||
@@ -184,15 +213,27 @@ export function App() {
|
||||
updateLibrary();
|
||||
}),
|
||||
window.electron.onSignOut(() => clearUserDetails()),
|
||||
window.electron.onExtractionProgress((shop, objectId, progress) => {
|
||||
dispatch(setExtractionProgress({ shop, objectId, progress }));
|
||||
}),
|
||||
window.electron.onExtractionComplete(() => {
|
||||
dispatch(clearExtraction());
|
||||
updateLibrary();
|
||||
}),
|
||||
window.electron.onArchiveDeletionPrompt((paths) => {
|
||||
setArchivePaths(paths);
|
||||
setShowArchiveDeletionModal(true);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
listeners.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [onSignIn, updateLibrary, clearUserDetails]);
|
||||
}, [onSignIn, updateLibrary, clearUserDetails, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
workwondersRef.current?.notifyUrlChange();
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -281,14 +322,11 @@ export function App() {
|
||||
feature={hydraCloudFeature}
|
||||
/>
|
||||
|
||||
{userDetails && (
|
||||
<UserFriendModal
|
||||
visible={isFriendsModalVisible}
|
||||
initialTab={friendRequetsModalTab}
|
||||
onClose={hideFriendsModal}
|
||||
userId={friendModalUserId}
|
||||
/>
|
||||
)}
|
||||
<ArchiveDeletionModal
|
||||
visible={showArchiveDeletionModal}
|
||||
archivePaths={archivePaths}
|
||||
onClose={() => setShowArchiveDeletionModal(false)}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<Sidebar />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useToast,
|
||||
@@ -26,6 +27,8 @@ export function BottomPanel() {
|
||||
|
||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||
|
||||
const extraction = useAppSelector((state) => state.download.extraction);
|
||||
|
||||
const [version, setVersion] = useState("");
|
||||
const [sessionHash, setSessionHash] = useState<null | string>("");
|
||||
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
|
||||
@@ -68,6 +71,20 @@ export function BottomPanel() {
|
||||
return t("installing_common_redist", { log: commonRedistStatus });
|
||||
}
|
||||
|
||||
if (extraction) {
|
||||
const extractingGame = library.find(
|
||||
(game) => game.id === extraction.visibleId
|
||||
);
|
||||
|
||||
if (extractingGame) {
|
||||
const extractionPercentage = Math.round(extraction.progress * 100);
|
||||
return t("extracting", {
|
||||
title: extractingGame.title,
|
||||
percentage: `${extractionPercentage}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const game = lastPacket
|
||||
? library.find((game) => game.id === lastPacket?.gameId)
|
||||
: undefined;
|
||||
@@ -109,6 +126,7 @@ export function BottomPanel() {
|
||||
eta,
|
||||
downloadSpeed,
|
||||
commonRedistStatus,
|
||||
extraction,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -122,10 +140,10 @@ export function BottomPanel() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-featurebase-changelog
|
||||
data-open-workwonders-changelog-mini
|
||||
className="bottom-panel__version-button"
|
||||
>
|
||||
<small data-featurebase-changelog>
|
||||
<small>
|
||||
{sessionHash ? `${sessionHash} -` : ""} v{version} "
|
||||
{VERSION_CODENAME}"
|
||||
</small>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface DropdownMenuProps {
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
collisionPadding?: number;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
@@ -29,6 +30,7 @@ export function DropdownMenu({
|
||||
loop = true,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
collisionPadding = 16,
|
||||
}: Readonly<DropdownMenuProps>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root>
|
||||
@@ -43,6 +45,7 @@ export function DropdownMenu({
|
||||
loop={loop}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className="dropdown-menu__content"
|
||||
>
|
||||
{title && (
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.fullscreen-media-modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: globals.$backdrop-z-index;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fullscreen-media-modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
|
||||
&__close-button {
|
||||
position: absolute;
|
||||
top: calc(globals.$spacing-unit * 5);
|
||||
right: calc(globals.$spacing-unit * 4);
|
||||
cursor: pointer;
|
||||
color: globals.$body-color;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
border: 1px solid globals.$border-color;
|
||||
padding: globals.$spacing-unit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all ease 0.2s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__image-container {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__image {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes image-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import "./fullscreen-media-modal.scss";
|
||||
|
||||
export interface FullscreenMediaModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
src: string | null | undefined;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export function FullscreenMediaModal({
|
||||
visible,
|
||||
onClose,
|
||||
src,
|
||||
alt,
|
||||
}: FullscreenMediaModalProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { t } = useTranslation("modal");
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [onClose, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current) {
|
||||
const clickedOnImage = containerRef.current.contains(e.target as Node);
|
||||
|
||||
if (!clickedOnImage) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onMouseDown);
|
||||
};
|
||||
}, [onClose, visible]);
|
||||
|
||||
if (!visible || !src) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fullscreen-media-modal__overlay">
|
||||
<dialog className="fullscreen-media-modal" open aria-label={alt}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="fullscreen-media-modal__close-button"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<XIcon size={24} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fullscreen-media-modal__image-container"
|
||||
>
|
||||
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
|
||||
</div>
|
||||
</dialog>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export function Header() {
|
||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||
if (location.pathname.startsWith("/notifications")) return headerTitle;
|
||||
if (location.pathname.startsWith("/library"))
|
||||
return headerTitle || t("library");
|
||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||
@@ -323,7 +324,8 @@ export function Header() {
|
||||
<SearchDropdown
|
||||
visible={
|
||||
isDropdownVisible &&
|
||||
(historyItems.length > 0 ||
|
||||
(searchValue.trim().length > 0 ||
|
||||
historyItems.length > 0 ||
|
||||
suggestions.length > 0 ||
|
||||
isLoadingSuggestions)
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from "./game-context-menu/game-context-menu";
|
||||
export * from "./game-context-menu/use-game-actions";
|
||||
export * from "./star-rating/star-rating";
|
||||
export * from "./search-dropdown/search-dropdown";
|
||||
export * from "./fullscreen-media-modal/fullscreen-media-modal";
|
||||
|
||||
@@ -10,7 +10,7 @@ import cn from "classnames";
|
||||
|
||||
export interface ModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
large?: boolean;
|
||||
@@ -115,7 +115,6 @@ export function Modal({
|
||||
"modal--large": large,
|
||||
})}
|
||||
role="dialog"
|
||||
aria-labelledby={title}
|
||||
aria-describedby={description}
|
||||
ref={modalContentRef}
|
||||
data-hydra-dialog
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
background-color: globals.$dark-background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 8px;
|
||||
max-height: 300px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import cn from "classnames";
|
||||
@@ -92,23 +92,8 @@ export function SearchDropdown({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [visible, onClose, searchContainerRef]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(
|
||||
type: "history" | "suggestion",
|
||||
item: SearchHistoryEntry | SearchSuggestion
|
||||
) => {
|
||||
if (type === "history") {
|
||||
onSelectHistory((item as SearchHistoryEntry).query);
|
||||
} else {
|
||||
onSelectSuggestion(item as SearchSuggestion);
|
||||
}
|
||||
},
|
||||
[onSelectHistory, onSelectSuggestion]
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const totalItems = historyItems.length + suggestions.length;
|
||||
const hasHistory = historyItems.length > 0;
|
||||
const hasSuggestions = suggestions.length > 0;
|
||||
|
||||
@@ -158,7 +143,7 @@ export function SearchDropdown({
|
||||
activeIndex === getItemIndex("history", index),
|
||||
})}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleItemClick("history", item)}
|
||||
onClick={() => onSelectHistory(item.query)}
|
||||
>
|
||||
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
||||
<span className="search-dropdown__item-text">
|
||||
@@ -200,7 +185,7 @@ export function SearchDropdown({
|
||||
activeIndex === getItemIndex("suggestion", index),
|
||||
})}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleItemClick("suggestion", item)}
|
||||
onClick={() => onSelectSuggestion(item)}
|
||||
>
|
||||
{item.iconUrl ? (
|
||||
<img
|
||||
@@ -227,13 +212,6 @@ export function SearchDropdown({
|
||||
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
||||
<div className="search-dropdown__loading">{t("loading")}</div>
|
||||
)}
|
||||
|
||||
{!isLoadingSuggestions &&
|
||||
!hasHistory &&
|
||||
!hasSuggestions &&
|
||||
totalItems === 0 && (
|
||||
<div className="search-dropdown__empty">{t("no_results")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import cn from "classnames";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { GameContextMenu } from "..";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
|
||||
interface SidebarGameItemProps {
|
||||
game: LibraryGame;
|
||||
@@ -18,6 +19,9 @@ export function SidebarGameItem({
|
||||
getGameTitle,
|
||||
}: Readonly<SidebarGameItemProps>) {
|
||||
const location = useLocation();
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
@@ -81,11 +85,12 @@ export function SidebarGameItem({
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
|
||||
{(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||
<span className="sidebar__game-badge">
|
||||
+{game.newDownloadOptionsCount}
|
||||
</span>
|
||||
)}
|
||||
{userPreferences?.enableNewDownloadOptionsBadges !== false &&
|
||||
(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||
<span className="sidebar__game-badge">
|
||||
+{game.newDownloadOptionsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__friends-button {
|
||||
&__notification-button {
|
||||
color: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__friends-button-badge {
|
||||
&__notification-button-badge {
|
||||
background-color: globals.$success-color;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -73,6 +73,8 @@
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__game-running-icon {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PeopleIcon } from "@primer/octicons-react";
|
||||
import { BellIcon } from "@primer/octicons-react";
|
||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { Avatar } from "../avatar/avatar";
|
||||
import { AuthPage } from "@shared";
|
||||
import { logger } from "@renderer/logger";
|
||||
import type { NotificationCountResponse } from "@types";
|
||||
import "./sidebar-profile.scss";
|
||||
|
||||
export function SidebarProfile() {
|
||||
@@ -14,11 +15,75 @@ export function SidebarProfile() {
|
||||
|
||||
const { t } = useTranslation("sidebar");
|
||||
|
||||
const { userDetails, friendRequestCount, showFriendsModal } =
|
||||
useUserDetails();
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
const [notificationCount, setNotificationCount] = useState(0);
|
||||
|
||||
const fetchNotificationCount = useCallback(async () => {
|
||||
try {
|
||||
// Always fetch local notification count
|
||||
const localCount = await window.electron.getLocalNotificationsCount();
|
||||
|
||||
// Fetch API notification count only if logged in
|
||||
let apiCount = 0;
|
||||
if (userDetails) {
|
||||
try {
|
||||
const response =
|
||||
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||
"/profile/notifications/count",
|
||||
{ needsAuth: true }
|
||||
);
|
||||
apiCount = response.count;
|
||||
} catch {
|
||||
// Ignore API errors
|
||||
}
|
||||
}
|
||||
|
||||
setNotificationCount(localCount + apiCount);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch notification count", error);
|
||||
}
|
||||
}, [userDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationCount();
|
||||
|
||||
const interval = setInterval(fetchNotificationCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||
fetchNotificationCount();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationsChange = () => {
|
||||
fetchNotificationCount();
|
||||
};
|
||||
|
||||
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"notificationsChanged",
|
||||
handleNotificationsChange
|
||||
);
|
||||
};
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncNotificationCount(() => {
|
||||
fetchNotificationCount();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
if (userDetails === null) {
|
||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||
@@ -28,28 +93,24 @@ export function SidebarProfile() {
|
||||
navigate(`/profile/${userDetails.id}`);
|
||||
};
|
||||
|
||||
const friendsButton = useMemo(() => {
|
||||
if (!userDetails) return null;
|
||||
|
||||
const notificationsButton = useMemo(() => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-profile__friends-button"
|
||||
onClick={() =>
|
||||
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
|
||||
}
|
||||
title={t("friends")}
|
||||
className="sidebar-profile__notification-button"
|
||||
onClick={() => navigate("/notifications")}
|
||||
title={t("notifications")}
|
||||
>
|
||||
{friendRequestCount > 0 && (
|
||||
<small className="sidebar-profile__friends-button-badge">
|
||||
{friendRequestCount > 99 ? "99+" : friendRequestCount}
|
||||
{notificationCount > 0 && (
|
||||
<small className="sidebar-profile__notification-button-badge">
|
||||
{notificationCount > 99 ? "99+" : notificationCount}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<PeopleIcon size={16} />
|
||||
<BellIcon size={16} />
|
||||
</button>
|
||||
);
|
||||
}, [userDetails, t, friendRequestCount, showFriendsModal]);
|
||||
}, [t, notificationCount, navigate]);
|
||||
|
||||
const gameRunningDetails = () => {
|
||||
if (!userDetails || !gameRunning) return null;
|
||||
@@ -98,7 +159,7 @@ export function SidebarProfile() {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{friendsButton}
|
||||
{notificationsButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export const VERSION_CODENAME = "Supernova";
|
||||
export const VERSION_CODENAME = "Harbinger";
|
||||
|
||||
export const DOWNLOADER_NAME = {
|
||||
[Downloader.RealDebrid]: "Real-Debrid",
|
||||
@@ -10,8 +10,11 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.Buzzheavier]: "Buzzheavier",
|
||||
[Downloader.FuckingFast]: "FuckingFast",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
[Downloader.VikingFile]: "VikingFile",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
22
src/renderer/src/declaration.d.ts
vendored
22
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,7 @@ import type {
|
||||
GameStats,
|
||||
UserDetails,
|
||||
FriendRequestSync,
|
||||
NotificationSync,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
Game,
|
||||
DiskUsage,
|
||||
DownloadSource,
|
||||
LocalNotification,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
@@ -208,6 +210,13 @@ declare global {
|
||||
onExtractionComplete: (
|
||||
cb: (shop: GameShop, objectId: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onExtractionProgress: (
|
||||
cb: (shop: GameShop, objectId: string, progress: number) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onArchiveDeletionPrompt: (
|
||||
cb: (archivePaths: string[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
deleteArchive: (filePath: string) => Promise<boolean>;
|
||||
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
|
||||
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
|
||||
@@ -380,10 +389,12 @@ declare global {
|
||||
processProfileImage: (
|
||||
path: string
|
||||
) => Promise<{ imagePath: string; mimeType: string }>;
|
||||
syncFriendRequests: () => Promise<void>;
|
||||
onSyncFriendRequests: (
|
||||
cb: (friendRequests: FriendRequestSync) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onSyncNotificationCount: (
|
||||
cb: (notification: NotificationSync) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
updateFriendRequest: (
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
@@ -391,6 +402,15 @@ declare global {
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||
getLocalNotifications: () => Promise<LocalNotification[]>;
|
||||
getLocalNotificationsCount: () => Promise<number>;
|
||||
markLocalNotificationRead: (id: string) => Promise<void>;
|
||||
markAllLocalNotificationsRead: () => Promise<void>;
|
||||
deleteLocalNotification: (id: string) => Promise<void>;
|
||||
clearAllLocalNotifications: () => Promise<void>;
|
||||
onLocalNotificationCreated: (
|
||||
cb: (notification: LocalNotification) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
position?: AchievementCustomNotificationPosition,
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { DownloadProgress } from "@types";
|
||||
import type { DownloadProgress, GameShop } from "@types";
|
||||
|
||||
export interface ExtractionInfo {
|
||||
visibleId: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface DownloadState {
|
||||
lastPacket: DownloadProgress | null;
|
||||
gameId: string | null;
|
||||
gamesWithDeletionInProgress: string[];
|
||||
extraction: ExtractionInfo | null;
|
||||
peakSpeeds: Record<string, number>;
|
||||
speedHistory: Record<string, number[]>;
|
||||
}
|
||||
|
||||
const initialState: DownloadState = {
|
||||
lastPacket: null,
|
||||
gameId: null,
|
||||
gamesWithDeletionInProgress: [],
|
||||
extraction: null,
|
||||
peakSpeeds: {},
|
||||
speedHistory: {},
|
||||
};
|
||||
|
||||
export const downloadSlice = createSlice({
|
||||
@@ -21,6 +32,27 @@ export const downloadSlice = createSlice({
|
||||
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
||||
state.lastPacket = action.payload;
|
||||
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
|
||||
|
||||
// Track peak speed and speed history atomically when packet arrives
|
||||
if (action.payload?.gameId && action.payload.downloadSpeed != null) {
|
||||
const { gameId, downloadSpeed } = action.payload;
|
||||
|
||||
// Update peak speed if this is higher
|
||||
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||
if (downloadSpeed > currentPeak) {
|
||||
state.peakSpeeds[gameId] = downloadSpeed;
|
||||
}
|
||||
|
||||
// Update speed history for chart
|
||||
if (!state.speedHistory[gameId]) {
|
||||
state.speedHistory[gameId] = [];
|
||||
}
|
||||
state.speedHistory[gameId].push(downloadSpeed);
|
||||
// Keep only last 120 entries
|
||||
if (state.speedHistory[gameId].length > 120) {
|
||||
state.speedHistory[gameId].shift();
|
||||
}
|
||||
}
|
||||
},
|
||||
clearDownload: (state) => {
|
||||
state.lastPacket = null;
|
||||
@@ -38,6 +70,37 @@ export const downloadSlice = createSlice({
|
||||
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
|
||||
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
|
||||
},
|
||||
setExtractionProgress: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
progress: number;
|
||||
}>
|
||||
) => {
|
||||
const { shop, objectId, progress } = action.payload;
|
||||
state.extraction = {
|
||||
visibleId: `${shop}:${objectId}`,
|
||||
progress,
|
||||
};
|
||||
},
|
||||
clearExtraction: (state) => {
|
||||
state.extraction = null;
|
||||
},
|
||||
updatePeakSpeed: (
|
||||
state,
|
||||
action: PayloadAction<{ gameId: string; speed: number }>
|
||||
) => {
|
||||
const { gameId, speed } = action.payload;
|
||||
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||
if (speed > currentPeak) {
|
||||
state.peakSpeeds[gameId] = speed;
|
||||
}
|
||||
},
|
||||
clearPeakSpeed: (state, action: PayloadAction<string>) => {
|
||||
state.peakSpeeds[action.payload] = 0;
|
||||
state.speedHistory[action.payload] = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,4 +109,8 @@ export const {
|
||||
clearDownload,
|
||||
setGameDeleting,
|
||||
removeGameFromDeleting,
|
||||
setExtractionProgress,
|
||||
clearExtraction,
|
||||
updatePeakSpeed,
|
||||
clearPeakSpeed,
|
||||
} = downloadSlice.actions;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import type { FriendRequest, UserDetails } from "@types";
|
||||
|
||||
export interface UserDetailsState {
|
||||
@@ -7,9 +6,6 @@ export interface UserDetailsState {
|
||||
profileBackground: null | string;
|
||||
friendRequests: FriendRequest[];
|
||||
friendRequestCount: number;
|
||||
isFriendsModalVisible: boolean;
|
||||
friendRequetsModalTab: UserFriendModalTab | null;
|
||||
friendModalUserId: string;
|
||||
}
|
||||
|
||||
const initialState: UserDetailsState = {
|
||||
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
|
||||
profileBackground: null,
|
||||
friendRequests: [],
|
||||
friendRequestCount: 0,
|
||||
isFriendsModalVisible: false,
|
||||
friendRequetsModalTab: null,
|
||||
friendModalUserId: "",
|
||||
};
|
||||
|
||||
export const userDetailsSlice = createSlice({
|
||||
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
|
||||
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
||||
state.friendRequestCount = action.payload;
|
||||
},
|
||||
setFriendsModalVisible: (
|
||||
state,
|
||||
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
|
||||
) => {
|
||||
state.isFriendsModalVisible = true;
|
||||
state.friendRequetsModalTab = action.payload.initialTab;
|
||||
state.friendModalUserId = action.payload.userId;
|
||||
},
|
||||
setFriendsModalHidden: (state) => {
|
||||
state.isFriendsModalVisible = false;
|
||||
state.friendRequetsModalTab = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,6 +39,4 @@ export const {
|
||||
setProfileBackground,
|
||||
setFriendRequests,
|
||||
setFriendRequestCount,
|
||||
setFriendsModalVisible,
|
||||
setFriendsModalHidden,
|
||||
} = userDetailsSlice.actions;
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./use-download-options-listener";
|
||||
export * from "./use-game-card";
|
||||
export * from "./use-search-history";
|
||||
export * from "./use-search-suggestions";
|
||||
export * from "./use-hls-video";
|
||||
|
||||
102
src/renderer/src/hooks/use-hls-video.ts
Normal file
102
src/renderer/src/hooks/use-hls-video.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
interface UseHlsVideoOptions {
|
||||
videoSrc: string | undefined;
|
||||
videoType: string | undefined;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export function useHlsVideo(
|
||||
videoRef: React.RefObject<HTMLVideoElement>,
|
||||
{ videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions
|
||||
) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !videoSrc) return;
|
||||
|
||||
const isHls = videoType === "application/x-mpegURL";
|
||||
|
||||
if (!isHls) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
});
|
||||
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.loadSource(videoSrc);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
logger.warn("Failed to autoplay HLS video:", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
logger.error("HLS network error, trying to recover");
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
logger.error("HLS media error, trying to recover");
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
logger.error("HLS fatal error, destroying instance");
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
};
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = videoSrc;
|
||||
video.load();
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
logger.warn("Failed to autoplay HLS video:", err);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.src = "";
|
||||
};
|
||||
} else {
|
||||
logger.warn("HLS playback is not supported in this browser");
|
||||
return undefined;
|
||||
}
|
||||
}, [videoRef, videoSrc, videoType, autoplay, muted, loop]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (muted !== undefined) {
|
||||
video.muted = muted;
|
||||
}
|
||||
if (loop !== undefined) {
|
||||
video.loop = loop;
|
||||
}
|
||||
}, [videoRef, muted, loop]);
|
||||
|
||||
return hlsRef.current;
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
setProfileBackground,
|
||||
setUserDetails,
|
||||
setFriendRequests,
|
||||
setFriendsModalVisible,
|
||||
setFriendsModalHidden,
|
||||
} from "@renderer/features";
|
||||
import type {
|
||||
FriendRequestAction,
|
||||
@@ -13,20 +11,12 @@ import type {
|
||||
UserDetails,
|
||||
FriendRequest,
|
||||
} from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
|
||||
export function useUserDetails() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
friendRequests,
|
||||
friendRequestCount,
|
||||
isFriendsModalVisible,
|
||||
friendModalUserId,
|
||||
friendRequetsModalTab,
|
||||
} = useAppSelector((state) => state.userDetails);
|
||||
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
|
||||
useAppSelector((state) => state.userDetails);
|
||||
|
||||
const clearUserDetails = useCallback(async () => {
|
||||
dispatch(setUserDetails(null));
|
||||
@@ -69,6 +59,7 @@ export function useUserDetails() {
|
||||
username: userDetails?.username || "",
|
||||
subscription: userDetails?.subscription || null,
|
||||
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
||||
workwondersJwt: userDetails?.workwondersJwt || "",
|
||||
karma: userDetails?.karma || 0,
|
||||
});
|
||||
},
|
||||
@@ -85,24 +76,11 @@ export function useUserDetails() {
|
||||
return window.electron.hydraApi
|
||||
.get<FriendRequest[]>("/profile/friend-requests")
|
||||
.then((friendRequests) => {
|
||||
window.electron.syncFriendRequests();
|
||||
dispatch(setFriendRequests(friendRequests));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [dispatch]);
|
||||
|
||||
const showFriendsModal = useCallback(
|
||||
(initialTab: UserFriendModalTab, userId: string) => {
|
||||
dispatch(setFriendsModalVisible({ initialTab, userId }));
|
||||
fetchFriendRequests();
|
||||
},
|
||||
[dispatch, fetchFriendRequests]
|
||||
);
|
||||
|
||||
const hideFriendsModal = useCallback(() => {
|
||||
dispatch(setFriendsModalHidden());
|
||||
}, [dispatch]);
|
||||
|
||||
const sendFriendRequest = useCallback(
|
||||
async (userId: string) => {
|
||||
return window.electron.hydraApi
|
||||
@@ -134,7 +112,7 @@ export function useUserDetails() {
|
||||
);
|
||||
|
||||
const undoFriendship = (userId: string) =>
|
||||
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
|
||||
window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
|
||||
|
||||
const blockUser = (userId: string) =>
|
||||
window.electron.hydraApi.post(`/users/${userId}/block`);
|
||||
@@ -152,12 +130,7 @@ export function useUserDetails() {
|
||||
profileBackground,
|
||||
friendRequests,
|
||||
friendRequestCount,
|
||||
friendRequetsModalTab,
|
||||
isFriendsModalVisible,
|
||||
friendModalUserId,
|
||||
hasActiveSubscription,
|
||||
showFriendsModal,
|
||||
hideFriendsModal,
|
||||
fetchUserDetails,
|
||||
signOut,
|
||||
clearUserDetails,
|
||||
|
||||
@@ -31,6 +31,7 @@ import Profile from "./pages/profile/profile";
|
||||
import Achievements from "./pages/achievements/achievements";
|
||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||
import Library from "./pages/library/library";
|
||||
import Notifications from "./pages/notifications/notifications";
|
||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||
|
||||
console.log = logger.log;
|
||||
@@ -76,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/profile/:userId" element={<Profile />} />
|
||||
<Route path="/achievements" element={<Achievements />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmationModal } from "@renderer/components";
|
||||
|
||||
interface ArchiveDeletionModalProps {
|
||||
visible: boolean;
|
||||
archivePaths: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ArchiveDeletionModal({
|
||||
visible,
|
||||
archivePaths,
|
||||
onClose,
|
||||
}: Readonly<ArchiveDeletionModalProps>) {
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const fullFileName =
|
||||
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
|
||||
|
||||
const maxLength = 40;
|
||||
const fileName =
|
||||
fullFileName.length > maxLength
|
||||
? `${fullFileName.slice(0, maxLength)}…`
|
||||
: fullFileName;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
for (const archivePath of archivePaths) {
|
||||
await window.electron.deleteArchive(archivePath);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
visible={visible}
|
||||
title={t("delete_archive_title", { fileName })}
|
||||
descriptionText={t("delete_archive_description")}
|
||||
confirmButtonLabel={t("yes")}
|
||||
cancelButtonLabel={t("no")}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,158 +4,541 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
margin-inline: calc(globals.$spacing-unit * 3);
|
||||
padding-block: calc(globals.$spacing-unit * 3);
|
||||
|
||||
&__details-with-article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
align-self: flex-start;
|
||||
cursor: pointer;
|
||||
&--queued {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
padding-top: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit);
|
||||
|
||||
&-divider {
|
||||
&-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
flex: 1;
|
||||
background-color: globals.$border-color;
|
||||
height: 1px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
&-count {
|
||||
font-weight: 400;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: globals.$spacing-unit;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: globals.$body-color;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__downloads {
|
||||
&--hero {
|
||||
width: 100%;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: globals.$spacing-unit;
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__item {
|
||||
&__hero-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: globals.$background-color;
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
border: solid 1px globals.$border-color;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 0px 5px 0px #000000;
|
||||
transition: all ease 0.2s;
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
max-height: 140px;
|
||||
position: relative;
|
||||
height: 120%;
|
||||
z-index: 0;
|
||||
|
||||
&--hydra {
|
||||
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 20%;
|
||||
}
|
||||
}
|
||||
&__cover {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
height: auto;
|
||||
border-right: solid 1px globals.$border-color;
|
||||
// PLEASE FIX THE COLORS
|
||||
&__hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.3) 0%,
|
||||
rgb(5, 5, 5) 70%,
|
||||
rgb(26, 26, 26) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&__hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: globals.$spacing-unit;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-backdrop {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.8) 5%,
|
||||
transparent 100%
|
||||
);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
&__right-content {
|
||||
display: flex;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
flex: 1;
|
||||
gap: globals.$spacing-unit;
|
||||
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
|
||||
}
|
||||
|
||||
&__details {
|
||||
padding: calc(globals.$spacing-unit * 4);
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-size: 14px;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
&__hero-logo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
|
||||
&-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: scale 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
scale: 1.05;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 180px;
|
||||
max-height: 60px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
max-width: 220px;
|
||||
max-height: 75px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 900px) {
|
||||
max-width: 280px;
|
||||
max-height: 95px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1200px) {
|
||||
max-width: 340px;
|
||||
max-height: 115px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1500px) {
|
||||
max-width: 400px;
|
||||
max-height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 900px) {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1200px) {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1500px) {
|
||||
font-size: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-radius: 50%;
|
||||
&__hero-action-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
margin-top: calc(globals.$spacing-unit * 4);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__hero-buttons {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__glass-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__progress-info-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__progress-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&--bar {
|
||||
margin-top: calc(globals.$spacing-unit);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__progress-percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
align-self: flex-end;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__hero-stats {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 4);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(26, 26, 26, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
margin-top: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__stats-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
min-width: 200px;
|
||||
padding-right: calc(globals.$spacing-unit * 2);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__speed-chart {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__speed-chart-canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
&__stat-item {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
|
||||
svg {
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__stat-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__simple-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__simple-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__simple-thumbnail {
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid globals.$border-color;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__simple-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 1);
|
||||
}
|
||||
|
||||
&__simple-title-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__simple-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__simple-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__simple-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
font-size: 13px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__simple-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__simple-extracting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__simple-seeding {
|
||||
color: #4ade80;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__simple-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__simple-progress-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__simple-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
}
|
||||
|
||||
&__simple-menu-btn {
|
||||
padding: calc(globals.$spacing-unit);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
&__hydra-gradient {
|
||||
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%);
|
||||
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15);
|
||||
&__progress-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
z-index: 1;
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
|
||||
&--small {
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
|
||||
&--extraction {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@
|
||||
.downloads {
|
||||
&__container {
|
||||
display: flex;
|
||||
padding: calc(globals.$spacing-unit * 3);
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
@@ -13,6 +13,7 @@ import { ArrowDownIcon } from "@primer/octicons-react";
|
||||
|
||||
export default function Downloads() {
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const extraction = useAppSelector((state) => state.download.extraction);
|
||||
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
@@ -39,11 +40,13 @@ export default function Downloads() {
|
||||
useEffect(() => {
|
||||
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
|
||||
|
||||
const unsubscribe = window.electron.onExtractionComplete(() => {
|
||||
const unsubscribeExtraction = window.electron.onExtractionComplete(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
return () => {
|
||||
unsubscribeExtraction();
|
||||
};
|
||||
}, [updateLibrary]);
|
||||
|
||||
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
|
||||
@@ -72,8 +75,10 @@ export default function Downloads() {
|
||||
/* Game has been manually added to the library */
|
||||
if (!next.download) return prev;
|
||||
|
||||
/* Is downloading */
|
||||
if (lastPacket?.gameId === next.id || next.download.extracting)
|
||||
/* Is downloading or extracting */
|
||||
const isExtracting =
|
||||
next.download.extracting || extraction?.visibleId === next.id;
|
||||
if (lastPacket?.gameId === next.id || isExtracting)
|
||||
return { ...prev, downloading: [...prev.downloading, next] };
|
||||
|
||||
/* Is either queued or paused */
|
||||
@@ -96,7 +101,7 @@ export default function Downloads() {
|
||||
queued,
|
||||
complete,
|
||||
};
|
||||
}, [library, lastPacket?.gameId]);
|
||||
}, [library, lastPacket?.gameId, extraction?.visibleId]);
|
||||
|
||||
const downloadGroups = [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
import { VideoPlayer } from "./video-player";
|
||||
import "./gallery-slider.scss";
|
||||
|
||||
export function GallerySlider() {
|
||||
@@ -106,8 +107,6 @@ export function GallerySlider() {
|
||||
|
||||
if (shopDetails?.movies) {
|
||||
shopDetails.movies.forEach((video, index) => {
|
||||
// Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1
|
||||
// Fallback to old format: mp4/webm if new formats are not available
|
||||
let videoSrc: string | undefined;
|
||||
let videoType: string | undefined;
|
||||
|
||||
@@ -121,11 +120,9 @@ export function GallerySlider() {
|
||||
videoSrc = video.dash_av1;
|
||||
videoType = "application/dash+xml";
|
||||
} else if (video.mp4?.max) {
|
||||
// Fallback to old format
|
||||
videoSrc = video.mp4.max;
|
||||
videoType = "video/mp4";
|
||||
} else if (video.webm?.max) {
|
||||
// Fallback to webm if mp4 is not available
|
||||
videoSrc = video.webm.max;
|
||||
videoType = "video/webm";
|
||||
}
|
||||
@@ -191,19 +188,17 @@ export function GallerySlider() {
|
||||
{mediaItems.map((item) => (
|
||||
<div key={item.id} className="gallery-slider__slide">
|
||||
{item.type === "video" ? (
|
||||
<video
|
||||
controls
|
||||
className="gallery-slider__media"
|
||||
<VideoPlayer
|
||||
videoSrc={item.videoSrc}
|
||||
videoType={item.videoType}
|
||||
poster={item.poster}
|
||||
autoplay={autoplayEnabled}
|
||||
loop
|
||||
muted
|
||||
autoPlay={autoplayEnabled}
|
||||
controls
|
||||
className="gallery-slider__media"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{item.videoSrc && (
|
||||
<source src={item.videoSrc} type={item.videoType} />
|
||||
)}
|
||||
</video>
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className="gallery-slider__media"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from "react";
|
||||
import { useHlsVideo } from "@renderer/hooks";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoSrc?: string;
|
||||
videoType?: string;
|
||||
poster?: string;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
controls?: boolean;
|
||||
tabIndex?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
videoSrc,
|
||||
videoType,
|
||||
poster,
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop = false,
|
||||
controls = true,
|
||||
tabIndex = -1,
|
||||
className,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const isHls = videoType === "application/x-mpegURL";
|
||||
|
||||
useHlsVideo(videoRef, {
|
||||
videoSrc,
|
||||
videoType,
|
||||
autoplay,
|
||||
muted,
|
||||
loop,
|
||||
});
|
||||
|
||||
if (isHls) {
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls={controls}
|
||||
className={className}
|
||||
poster={poster}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
autoPlay={autoplay}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls={controls}
|
||||
className={className}
|
||||
poster={poster}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
autoPlay={autoplay}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{videoSrc && <source src={videoSrc} type={videoType} />}
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PencilIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
import { DescriptionHeader } from "./description-header/description-header";
|
||||
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
|
||||
|
||||
export function GameDetailsContent() {
|
||||
const { t } = useTranslation("game_details");
|
||||
const [searchParams] = useSearchParams();
|
||||
const reviewsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
objectId,
|
||||
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
|
||||
getGameArtifacts();
|
||||
}, [getGameArtifacts]);
|
||||
|
||||
// Scroll to reviews section if reviews=true in URL
|
||||
useEffect(() => {
|
||||
const shouldScrollToReviews = searchParams.get("reviews") === "true";
|
||||
if (shouldScrollToReviews && reviewsRef.current) {
|
||||
setTimeout(() => {
|
||||
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 500);
|
||||
}
|
||||
}, [searchParams, objectId]);
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
const heroImage = isCustomGame
|
||||
@@ -229,15 +242,17 @@ export function GameDetailsContent() {
|
||||
)}
|
||||
|
||||
{shop !== "custom" && shop && objectId && (
|
||||
<GameReviews
|
||||
shop={shop}
|
||||
objectId={objectId}
|
||||
game={game}
|
||||
userDetailsId={userDetails?.id}
|
||||
isGameInLibrary={isGameInLibrary}
|
||||
hasUserReviewed={hasUserReviewed}
|
||||
onUserReviewedChange={setHasUserReviewed}
|
||||
/>
|
||||
<div ref={reviewsRef}>
|
||||
<GameReviews
|
||||
shop={shop}
|
||||
objectId={objectId}
|
||||
game={game}
|
||||
userDetailsId={userDetails?.id}
|
||||
isGameInLibrary={isGameInLibrary}
|
||||
hasUserReviewed={hasUserReviewed}
|
||||
onUserReviewedChange={setHasUserReviewed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { useDate, useDownload, useFormat } from "@renderer/hooks";
|
||||
import {
|
||||
useAppSelector,
|
||||
useDate,
|
||||
useDownload,
|
||||
useFormat,
|
||||
} from "@renderer/hooks";
|
||||
import { Link } from "@renderer/components";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
@@ -17,6 +22,9 @@ export function HeroPanelPlaytime() {
|
||||
const { numberFormatter } = useFormat();
|
||||
const { progress, lastPacket } = useDownload();
|
||||
const { formatDistance } = useDate();
|
||||
const extraction = useAppSelector((state) => state.download.extraction);
|
||||
|
||||
const isExtracting = extraction?.visibleId === game?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.lastTimePlayed) {
|
||||
@@ -52,6 +60,16 @@ export function HeroPanelPlaytime() {
|
||||
const isGameDownloading =
|
||||
game.download?.status === "active" && lastPacket?.gameId === game.id;
|
||||
|
||||
const extractionInProgressInfo = (
|
||||
<div className="hero-panel-playtime__download-details">
|
||||
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
|
||||
{t("extracting")}
|
||||
</Link>
|
||||
|
||||
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const downloadInProgressInfo = (
|
||||
<div className="hero-panel-playtime__download-details">
|
||||
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
|
||||
@@ -72,7 +90,8 @@ export function HeroPanelPlaytime() {
|
||||
return (
|
||||
<>
|
||||
<p>{t("not_played_yet", { title: game?.title })}</p>
|
||||
{hasDownload && downloadInProgressInfo}
|
||||
{isExtracting && extractionInProgressInfo}
|
||||
{!isExtracting && hasDownload && downloadInProgressInfo}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +100,8 @@ export function HeroPanelPlaytime() {
|
||||
return (
|
||||
<>
|
||||
<p>{t("playing_now")}</p>
|
||||
{hasDownload && downloadInProgressInfo}
|
||||
{isExtracting && extractionInProgressInfo}
|
||||
{!isExtracting && hasDownload && downloadInProgressInfo}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -113,9 +133,9 @@ export function HeroPanelPlaytime() {
|
||||
})}
|
||||
</p>
|
||||
|
||||
{hasDownload ? (
|
||||
downloadInProgressInfo
|
||||
) : (
|
||||
{isExtracting && extractionInProgressInfo}
|
||||
{!isExtracting && hasDownload && downloadInProgressInfo}
|
||||
{!isExtracting && !hasDownload && (
|
||||
<p>
|
||||
{t("last_time_played", {
|
||||
period: lastTimePlayed,
|
||||
|
||||
@@ -80,5 +80,11 @@
|
||||
&--disabled {
|
||||
opacity: globals.$disabled-opacity;
|
||||
}
|
||||
|
||||
&--extraction {
|
||||
&::-webkit-progress-value {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useDate, useDownload } from "@renderer/hooks";
|
||||
import { useAppSelector, useDate, useDownload } from "@renderer/hooks";
|
||||
|
||||
import { HeroPanelActions } from "./hero-panel-actions";
|
||||
import { HeroPanelPlaytime } from "./hero-panel-playtime";
|
||||
@@ -18,9 +18,13 @@ export function HeroPanel() {
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const extraction = useAppSelector((state) => state.download.extraction);
|
||||
|
||||
const isGameDownloading =
|
||||
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
|
||||
|
||||
const isExtracting = extraction?.visibleId === game?.id;
|
||||
|
||||
const getInfo = () => {
|
||||
if (!game) {
|
||||
const [latestRepack] = repacks;
|
||||
@@ -49,6 +53,8 @@ export function HeroPanel() {
|
||||
(game?.download?.status === "active" && game?.download?.progress < 1) ||
|
||||
game?.download?.status === "paused";
|
||||
|
||||
const showExtractionProgressBar = isExtracting;
|
||||
|
||||
return (
|
||||
<div className="hero-panel__container">
|
||||
<div className="hero-panel">
|
||||
@@ -72,6 +78,14 @@ export function HeroPanel() {
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtractionProgressBar && (
|
||||
<progress
|
||||
max={1}
|
||||
value={extraction?.progress ?? 0}
|
||||
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,23 +19,173 @@
|
||||
color: globals.$body-color;
|
||||
}
|
||||
|
||||
&__downloaders {
|
||||
display: grid;
|
||||
gap: globals.$spacing-unit;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
&__downloaders-list-wrapper {
|
||||
border: 1px solid globals.$border-color;
|
||||
overflow: hidden;
|
||||
background-color: globals.$dark-background-color;
|
||||
}
|
||||
|
||||
&__downloader-option {
|
||||
position: relative;
|
||||
&__downloaders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
|
||||
&:only-child {
|
||||
grid-column: 1 / -1;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__downloader-icon {
|
||||
position: absolute;
|
||||
left: calc(globals.$spacing-unit * 2);
|
||||
&__downloader-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px solid globals.$border-color;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
height: 48px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&--last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.download-settings-modal__downloader-name {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.download-settings-modal__availability-indicator-wrapper {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__downloader-item-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__check-icon {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__check-icon-wrapper {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__recommendation-badge {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__availability-indicator-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__availability-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--available {
|
||||
background-color: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
&--unavailable {
|
||||
background-color: #ef4444;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
&--not-present {
|
||||
background-color: #6b7280;
|
||||
box-shadow: 0 0 6px rgba(107, 114, 128, 0.5);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background-color: #eab308;
|
||||
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__availability-indicator--pulsating {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&__path-error {
|
||||
@@ -49,4 +199,17 @@
|
||||
&__change-path-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&__loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
CheckboxField,
|
||||
Link,
|
||||
Modal,
|
||||
TextField,
|
||||
} from "@renderer/components";
|
||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
||||
import {
|
||||
DownloadIcon,
|
||||
SyncIcon,
|
||||
CheckCircleFillIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
|
||||
import type { GameRepack } from "@types";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
|
||||
import { motion } from "framer-motion";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { RealDebridInfoModal } from "./real-debrid-info-modal";
|
||||
import "./download-settings-modal.scss";
|
||||
|
||||
export interface DownloadSettingsModalProps {
|
||||
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
|
||||
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
|
||||
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
|
||||
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
|
||||
}
|
||||
}, [visible, checkFolderWritePermission, selectedPath]);
|
||||
|
||||
const downloaders = useMemo(() => {
|
||||
return getDownloadersForUris(repack?.uris ?? []);
|
||||
}, [repack?.uris]);
|
||||
const downloadOptions = useMemo(() => {
|
||||
const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
|
||||
|
||||
const downloaderMap = new Map<
|
||||
Downloader,
|
||||
{ hasAvailable: boolean; hasUnavailable: boolean }
|
||||
>();
|
||||
|
||||
if (repack) {
|
||||
for (const uri of repack.uris) {
|
||||
const uriDownloaders = getDownloadersForUri(uri);
|
||||
const isAvailable = !unavailableUrisSet.has(uri);
|
||||
|
||||
for (const downloader of uriDownloaders) {
|
||||
const existing = downloaderMap.get(downloader);
|
||||
if (existing) {
|
||||
existing.hasAvailable = existing.hasAvailable || isAvailable;
|
||||
existing.hasUnavailable = existing.hasUnavailable || !isAvailable;
|
||||
} else {
|
||||
downloaderMap.set(downloader, {
|
||||
hasAvailable: isAvailable,
|
||||
hasUnavailable: !isAvailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allDownloaders = Object.values(Downloader).filter(
|
||||
(value) => typeof value === "number"
|
||||
) as Downloader[];
|
||||
|
||||
const getDownloaderPriority = (option: {
|
||||
isAvailable: boolean;
|
||||
canHandle: boolean;
|
||||
isAvailableButNotConfigured: boolean;
|
||||
}) => {
|
||||
if (option.isAvailable) return 0;
|
||||
if (option.canHandle && !option.isAvailableButNotConfigured) return 1;
|
||||
if (option.isAvailableButNotConfigured) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
return allDownloaders
|
||||
.filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus
|
||||
.map((downloader) => {
|
||||
const status = downloaderMap.get(downloader);
|
||||
const canHandle = status !== undefined;
|
||||
const isAvailable = status?.hasAvailable ?? false;
|
||||
|
||||
let isConfigured = true;
|
||||
if (downloader === Downloader.RealDebrid) {
|
||||
isConfigured = !!userPreferences?.realDebridApiToken;
|
||||
} else if (downloader === Downloader.TorBox) {
|
||||
isConfigured = !!userPreferences?.torBoxApiToken;
|
||||
}
|
||||
// } else if (downloader === Downloader.Hydra) {
|
||||
// isConfigured = isFeatureEnabled(Feature.Nimbus);
|
||||
// }
|
||||
|
||||
const isAvailableButNotConfigured =
|
||||
isAvailable && !isConfigured && canHandle;
|
||||
|
||||
return {
|
||||
downloader,
|
||||
isAvailable: isAvailable && isConfigured,
|
||||
canHandle,
|
||||
isAvailableButNotConfigured,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b));
|
||||
}, [
|
||||
repack,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
isFeatureEnabled,
|
||||
Feature,
|
||||
]);
|
||||
|
||||
const getDefaultDownloader = useCallback(
|
||||
(availableDownloaders: Downloader[]) => {
|
||||
if (availableDownloaders.length === 0) return null;
|
||||
|
||||
if (availableDownloaders.includes(Downloader.Hydra)) {
|
||||
return Downloader.Hydra;
|
||||
}
|
||||
|
||||
if (availableDownloaders.includes(Downloader.RealDebrid)) {
|
||||
return Downloader.RealDebrid;
|
||||
}
|
||||
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
|
||||
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
|
||||
}
|
||||
|
||||
const filteredDownloaders = downloaders.filter((downloader) => {
|
||||
if (downloader === Downloader.RealDebrid)
|
||||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
if (downloader === Downloader.Hydra)
|
||||
return isFeatureEnabled(Feature.Nimbus);
|
||||
return true;
|
||||
});
|
||||
const availableDownloaders = downloadOptions
|
||||
.filter((option) => option.isAvailable)
|
||||
.map((option) => option.downloader);
|
||||
|
||||
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
|
||||
}, [
|
||||
Feature,
|
||||
isFeatureEnabled,
|
||||
getDefaultDownloader,
|
||||
userPreferences?.downloadsPath,
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
]);
|
||||
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
|
||||
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
@@ -186,33 +252,144 @@ export function DownloadSettingsModal({
|
||||
<div className="download-settings-modal__downloads-path-field">
|
||||
<span>{t("downloader")}</span>
|
||||
|
||||
<div className="download-settings-modal__downloaders">
|
||||
{downloaders.map((downloader) => {
|
||||
const shouldDisableButton =
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(downloader === Downloader.Hydra &&
|
||||
!isFeatureEnabled(Feature.Nimbus));
|
||||
<div className="download-settings-modal__downloaders-list-wrapper">
|
||||
<div className="download-settings-modal__downloaders-list">
|
||||
{downloadOptions.map((option, index) => {
|
||||
const isSelected = selectedDownloader === option.downloader;
|
||||
const tooltipId = `availability-indicator-${option.downloader}`;
|
||||
const isLastItem = index === downloadOptions.length - 1;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={downloader}
|
||||
className="download-settings-modal__downloader-option"
|
||||
theme={
|
||||
selectedDownloader === downloader ? "primary" : "outline"
|
||||
const Indicator = option.isAvailable ? motion.span : "span";
|
||||
|
||||
const isDisabled =
|
||||
!option.canHandle ||
|
||||
(!option.isAvailable && !option.isAvailableButNotConfigured);
|
||||
|
||||
const getAvailabilityIndicator = () => {
|
||||
if (option.isAvailable) {
|
||||
return (
|
||||
<Indicator
|
||||
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--available download-settings-modal__availability-indicator--pulsating`}
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
opacity: [1, 0.7, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
data-tooltip-id={tooltipId}
|
||||
data-tooltip-content={t("downloader_online")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
disabled={shouldDisableButton}
|
||||
onClick={() => setSelectedDownloader(downloader)}
|
||||
>
|
||||
{selectedDownloader === downloader && (
|
||||
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
|
||||
)}
|
||||
{DOWNLOADER_NAME[downloader]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
if (option.isAvailableButNotConfigured) {
|
||||
return (
|
||||
<span
|
||||
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
|
||||
data-tooltip-id={tooltipId}
|
||||
data-tooltip-content={t("downloader_not_configured")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (option.canHandle) {
|
||||
return (
|
||||
<span
|
||||
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--unavailable`}
|
||||
data-tooltip-id={tooltipId}
|
||||
data-tooltip-content={t("downloader_offline")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
|
||||
data-tooltip-id={tooltipId}
|
||||
data-tooltip-content={t("downloader_not_available")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getRightContent = () => {
|
||||
if (isSelected) {
|
||||
return (
|
||||
<motion.div
|
||||
className="download-settings-modal__check-icon-wrapper"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
>
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="download-settings-modal__check-icon"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
option.downloader === Downloader.RealDebrid &&
|
||||
option.canHandle
|
||||
) {
|
||||
return (
|
||||
<div className="download-settings-modal__recommendation-badge">
|
||||
<Badge>{t("recommended")}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.downloader}
|
||||
className="download-settings-modal__downloader-item-wrapper"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`download-settings-modal__downloader-item ${
|
||||
isSelected
|
||||
? "download-settings-modal__downloader-item--selected"
|
||||
: ""
|
||||
} ${
|
||||
isLastItem
|
||||
? "download-settings-modal__downloader-item--last"
|
||||
: ""
|
||||
}`}
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
if (
|
||||
option.downloader === Downloader.RealDebrid &&
|
||||
option.isAvailableButNotConfigured
|
||||
) {
|
||||
setShowRealDebridModal(true);
|
||||
} else {
|
||||
setSelectedDownloader(option.downloader);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="download-settings-modal__downloader-name">
|
||||
{DOWNLOADER_NAME[option.downloader]}
|
||||
</span>
|
||||
<div className="download-settings-modal__availability-indicator-wrapper">
|
||||
{getAvailabilityIndicator()}
|
||||
</div>
|
||||
<Tooltip id={tooltipId} />
|
||||
{getRightContent()}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
|
||||
disabled={
|
||||
downloadStarting ||
|
||||
selectedDownloader === null ||
|
||||
!hasWritePermission
|
||||
!hasWritePermission ||
|
||||
downloadOptions.some(
|
||||
(option) =>
|
||||
option.downloader === selectedDownloader &&
|
||||
(option.isAvailableButNotConfigured ||
|
||||
(!option.isAvailable && option.canHandle) ||
|
||||
!option.canHandle)
|
||||
)
|
||||
}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{t("download_now")}
|
||||
{downloadStarting ? (
|
||||
<>
|
||||
<SyncIcon className="download-settings-modal__loading-spinner" />
|
||||
{t("loading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon />
|
||||
{t("download_now")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RealDebridInfoModal
|
||||
visible={showRealDebridModal}
|
||||
onClose={() => setShowRealDebridModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.real-debrid-info-modal {
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2.5);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
&__description-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: globals.$body-color;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__create-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
color: #c0c1c7;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Link, Modal } from "@renderer/components";
|
||||
import { LinkExternalIcon } from "@primer/octicons-react";
|
||||
import "./real-debrid-info-modal.scss";
|
||||
|
||||
const realDebridReferralId = import.meta.env
|
||||
.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID;
|
||||
|
||||
const REAL_DEBRID_URL = realDebridReferralId
|
||||
? `https://real-debrid.com/?id=${realDebridReferralId}`
|
||||
: "https://real-debrid.com";
|
||||
|
||||
export interface RealDebridInfoModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RealDebridInfoModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: Readonly<RealDebridInfoModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { t: tSettings } = useTranslation("settings");
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={tSettings("enable_real_debrid")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="real-debrid-info-modal__content">
|
||||
<div className="real-debrid-info-modal__description-container">
|
||||
<p className="real-debrid-info-modal__description">
|
||||
{tSettings("real_debrid_description")}
|
||||
</p>
|
||||
<Link
|
||||
to={REAL_DEBRID_URL}
|
||||
className="real-debrid-info-modal__create-account"
|
||||
>
|
||||
<LinkExternalIcon />
|
||||
{tSettings("create_real_debrid_account")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate("/settings?tab=4");
|
||||
}}
|
||||
>
|
||||
{t("go_to_settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,12 @@ import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { Downloader } from "@shared";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
|
||||
import {
|
||||
useDate,
|
||||
useFeature,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from "@renderer/hooks";
|
||||
import { clearNewDownloadOptions } from "@renderer/features";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
import { getGameKey } from "@renderer/helpers";
|
||||
@@ -70,6 +75,9 @@ export function RepacksModal({
|
||||
const { formatDate } = useDate();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const getHashFromMagnet = (magnet: string) => {
|
||||
if (!magnet || typeof magnet !== "string") {
|
||||
@@ -129,10 +137,12 @@ export function RepacksModal({
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) {
|
||||
fetchLastCheckTimestamp();
|
||||
} else {
|
||||
setIsLoadingTimestamp(false);
|
||||
}
|
||||
}, [visible, repacks]);
|
||||
}, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -231,9 +241,19 @@ export function RepacksModal({
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastCheckUtc = new Date(lastCheckTimestamp).toISOString();
|
||||
try {
|
||||
const lastCheckDate = new Date(lastCheckTimestamp);
|
||||
|
||||
return repack.createdAt > lastCheckUtc;
|
||||
if (isNaN(lastCheckDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastCheckUtc = lastCheckDate.toISOString();
|
||||
|
||||
return repack.createdAt > lastCheckUtc;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||
@@ -353,11 +373,13 @@ export function RepacksModal({
|
||||
>
|
||||
<p className="repacks-modal__repack-title">
|
||||
{repack.title}
|
||||
{isNewRepack(repack) && (
|
||||
<span className="repacks-modal__new-badge">
|
||||
{t("new_download_option")}
|
||||
</span>
|
||||
)}
|
||||
{userPreferences?.enableNewDownloadOptionsBadges !==
|
||||
false &&
|
||||
isNewRepack(repack) && (
|
||||
<span className="repacks-modal__new-badge">
|
||||
{t("new_download_option")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isLastDownloadedOption && (
|
||||
|
||||
@@ -221,6 +221,26 @@
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&__cover-placeholder {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.04) 50%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { LibraryGame } from "@types";
|
||||
import { useGameCard } from "@renderer/hooks";
|
||||
import { memo } from "react";
|
||||
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { memo, useState } from "react";
|
||||
import {
|
||||
ClockIcon,
|
||||
AlertFillIcon,
|
||||
TrophyIcon,
|
||||
ImageIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import "./library-game-card.scss";
|
||||
|
||||
interface LibraryGameCardProps {
|
||||
@@ -25,14 +30,9 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
||||
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
|
||||
useGameCard(game, onContextMenu);
|
||||
|
||||
const coverImage = (
|
||||
game.customIconUrl ??
|
||||
game.coverImageUrl ??
|
||||
game.libraryImageUrl ??
|
||||
game.libraryHeroImageUrl ??
|
||||
game.iconUrl ??
|
||||
""
|
||||
).replaceAll("\\", "/");
|
||||
const coverImage = game.coverImageUrl?.replaceAll("\\", "/") ?? "";
|
||||
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -98,12 +98,19 @@ export const LibraryGameCard = memo(function LibraryGameCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={coverImage ?? undefined}
|
||||
alt={game.title}
|
||||
className="library-game-card__game-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
{imageError || !coverImage ? (
|
||||
<div className="library-game-card__cover-placeholder">
|
||||
<ImageIcon size={48} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={game.title}
|
||||
className="library-game-card__game-image"
|
||||
loading="lazy"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user