Compare commits

...

123 Commits

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-08 23:29:01 +00:00
Moyasee
ed044d797f refactor: streamline download preparation and status handling in DownloadManager 2026-01-07 20:35:43 +02:00
Moyasee
ed3cce160f refactor: adjust file size handling in DownloadManager to ensure accurate download status updates 2026-01-07 17:32:46 +02:00
Moyasee
c67b275657 refactor: improve download initiation and error handling in DownloadManager 2026-01-07 17:26:29 +02:00
Moyasee
a2e866317d refactor: update interruptedDownload type to Download | null for improved type safety 2026-01-06 20:01:15 +02:00
Moyasee
a7c82de4a7 refactor: enhance download management by prioritizing interrupted downloads and improving error logging 2026-01-06 19:59:52 +02:00
Moyasee
027761a1b5 refactor: update cancelDownload method to conditionally delete file based on parameter 2026-01-06 19:18:42 +02:00
Moyasee
ca2f70aede refactor: enhance filename extraction and handling in download services 2026-01-06 18:50:36 +02:00
Moyasee
2b3a8bf6b6 refactor: replace type assertions with non-null assertions for download ID in DownloadManager 2026-01-06 18:11:22 +02:00
Moyasee
81b3ad3612 refactor: update download ID extraction and improve optional chaining in download services 2026-01-06 18:05:05 +02:00
Moyasee
8f477072ba refactor: improve error handling and download path preparation in JsHttpDownloader 2026-01-06 17:56:46 +02:00
Moyasee
569700e85c refactor: streamline download status updates in DownloadManager 2026-01-06 17:47:12 +02:00
Moyasee
4975f2def9 refactor: optimize chunk handling in JsHttpDownloader 2026-01-06 17:42:42 +02:00
Moyasee
77af7509ac feat: implement native HTTP downloader option and enhance download management 2026-01-06 17:41:05 +02:00
Sneezedip
f37ccbb4c0 Fix translation for hydra_cloud_feature_found 2026-01-06 12:33:14 -01:00
Moyasee
feb8d78e01 fix: update password index initialization in tryPassword function for correct behavior 2026-01-04 21:10:37 +02:00
Kiwo.2
44f39d94c4 Added new lines* 2026-01-04 14:07:34 +01:00
Kiwo.2
e618d313b3 Merge branch 'main' of https://github.com/Stormm232/hydra 2026-01-04 13:56:46 +01:00
Kiwo.2
7eec87c192 Added new lines 2026-01-04 13:56:32 +01:00
Moyasee
2ccc93ea61 feat: add empty state for friends box and new translation key 2026-01-04 04:23:59 +02:00
Zamitto
7e7390885e feat: adding ww feedback button
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-03 19:55:48 -03:00
Moyase
64815f4f8d Merge pull request #1910 from hydralauncher/feat/vikingfile-support
feat: VikingFile support and display url availability
2026-01-03 23:42:55 +02:00
Moyasee
4dfdc4d798 chore: remove commented code in DownloadSettingsModal 2026-01-03 23:40:07 +02:00
Moyasee
9bbfab2aff Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 23:35:00 +02:00
Moyasee
01938f8905 refactor: simplify downloader sorting and enhance availability indicators in DownloadSettingsModal 2026-01-03 23:34:19 +02:00
Moyase
f60ad5908d Merge branch 'main' into feat/vikingfile-support 2026-01-03 23:28:17 +02:00
Moyasee
fe6553bcdc chore: remove unused HTTPS import in vikingfile service 2026-01-03 23:23:35 +02:00
Moyasee
87895bb715 refactor: enhance disabled state styling and logic in DownloadSettingsModal 2026-01-03 23:22:40 +02:00
Chubby Granny Chaser
290209f372 chore: remove unnecessary blank lines in RealDebridInfoModal component files 2026-01-03 21:07:35 +00:00
Chubby Granny Chaser
87fcbaa56e chore: bump version to 3.8.0 and update translations for downloader status and notifications 2026-01-03 21:07:09 +00:00
Zamitto
c32ce14630 Merge pull request #1915 from hydralauncher/feat/add-workwonders
feat: add workwonders
2026-01-03 17:19:17 -03:00
Zamitto
e52f10a5ff chore: bump ww version 2026-01-03 17:17:16 -03:00
Moyase
bcdbe31596 Merge pull request #1914 from hydralauncher/fix/friend-request-endpoint
fix: update API endpoint for deleting friend requests in useUserDetai…
2026-01-03 22:08:45 +02:00
Zamitto
7ed514b6ef feat: parse locale before ww init 2026-01-03 16:54:42 -03:00
Moyase
42386ae0b5 Merge branch 'main' into fix/friend-request-endpoint 2026-01-03 21:48:52 +02:00
Moyase
04d8f900a6 Merge pull request #1913 from hydralauncher/fix/friends-and-karma-ui
refactor: remove karma description from translations across multiple …
2026-01-03 21:48:41 +02:00
Zamitto
8b3bcd88b1 feat: add workwonders 2026-01-03 16:42:49 -03:00
Moyasee
b2bffeb2b0 fix: update API endpoint for deleting friend requests in useUserDetails hook 2026-01-03 21:01:39 +02:00
Moyase
0a194eaa29 Merge branch 'main' into fix/friends-and-karma-ui 2026-01-03 20:13:08 +02:00
Moyasee
07c277c033 refactor: remove karma description from translations across multiple languages 2026-01-03 20:11:22 +02:00
Moyasee
345696ad06 Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 19:47:39 +02:00
Moyasee
6c4e8c406f refactor: update HTTP module imports to use node: prefix for consistency 2026-01-03 19:44:39 +02:00
Moyase
c46a1e7848 Merge branch 'main' into feat/vikingfile-support 2026-01-03 19:41:49 +02:00
Moyase
590e09a8c3 Merge pull request #1912 from hydralauncher/fix/notifications-page-ui
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: add unread filter option and enhance notifications UI
2026-01-03 19:41:30 +02:00
Moyasee
c1d7ea27f3 feat: add unread filter option and enhance notifications UI 2026-01-03 19:37:47 +02:00
Chubby Granny Chaser
15dbd3b2ad Merge pull request #1909 from hydralauncher/fix/library-game-covers
fix: library cards not using placeholder and icon as a game cover
2026-01-03 16:19:33 +00:00
Moyasee
4584783f44 refactor: enhance download progress tracking in DownloadManager 2026-01-03 04:47:46 +02:00
Moyasee
765ec70dd0 refactor: streamline downloader logic in DownloadSettingsModal 2026-01-03 01:40:21 +02:00
Moyasee
de483da51c fix: handle download not found exception in HttpDownloader and enforce IPv4 in HTTP agents 2026-01-03 01:08:25 +02:00
Moyasee
2bc0266775 feat: add loading state to download button and enhance UI with spinner 2026-01-03 00:18:07 +02:00
Moyasee
c9729fb3eb chore: update build and release workflows to include MAIN_VITE_NIMBUS_API_URL 2026-01-02 23:59:21 +02:00
Moyasee
9a7ad148e3 fix: use logger for error handling in VikingFile.ts 2026-01-02 23:24:20 +02:00
Moyasee
d929fbaeaa refactor: simplify header assignment in HttpDownloader 2026-01-02 23:23:08 +02:00
Moyasee
8fa33119d6 feat: add support for VikingFile and display if link is available 2026-01-02 23:20:08 +02:00
Moyasee
92d87c5d33 refactor: remove unnecessary useEffect in LibraryGameCard 2025-12-31 01:59:25 +02:00
Moyasee
af884d3772 refactor: simplify cover image assignment in LibraryGameCard 2025-12-30 14:09:04 +02:00
Moyasee
dc31ac0831 fix: library cards not using placeholder and icon as a game cover 2025-12-30 00:25:45 +02:00
Chubby Granny Chaser
9769eecec6 Merge pull request #1907 from hydralauncher/fix/lint-buzz
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
refactor: improve code formatting and consistency in DownloadManager
2025-12-27 00:56:29 +00:00
Moyasee
91adb97013 refactor: improve code formatting and consistency in DownloadManager 2025-12-27 02:50:27 +02:00
Chubby Granny Chaser
f138b2efcb Merge pull request #1906 from Wkeynhk/main
Buzzheavier fix
2025-12-27 00:47:44 +00:00
Wkeynhk
991aa05760 Sonar fix2 2025-12-27 03:23:36 +03:00
Wkeynhk
aff9e13bca Sonar fix 2025-12-27 03:17:42 +03:00
Wkeynhk
240a75c1d0 Buzzheavier fix 2025-12-27 03:02:35 +03:00
Chubby Granny Chaser
edbe86a1fb Merge pull request #1904 from hydralauncher/feat/LBX-155
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
fix: notification item styling
2025-12-26 22:54:13 +00:00
Moyasee
a01e1b1709 style: formatting 2025-12-27 00:51:26 +02:00
Moyase
60fd90820c Merge branch 'main' into feat/LBX-155 2025-12-27 00:44:35 +02:00
Moyasee
798f88618e style: enhance notification item styles with color adjustments and SVG inheritance 2025-12-27 00:42:23 +02:00
Moyasee
40795c34dc Merge branch 'feat/LBX-155' of https://github.com/hydralauncher/hydra into feat/LBX-155 2025-12-27 00:39:01 +02:00
Moyasee
e335e05628 style: update notification item styles for improved layout and alignment 2025-12-27 00:32:15 +02:00
Chubby Granny Chaser
05464f25df Merge pull request #1882 from keipa/patch-1
Adding chocolatey publishing
2025-12-26 22:20:57 +00:00
Chubby Granny Chaser
b9830afca1 Merge branch 'main' into patch-1 2025-12-26 22:20:33 +00:00
Chubby Granny Chaser
1cab73bcb4 Merge pull request #1865 from hydralauncher/feat/disabling-update-badges
feat: checkbox to disable new game update badges
2025-12-26 21:57:44 +00:00
Chubby Granny Chaser
27462c1e1e Merge branch 'main' into feat/disabling-update-badges 2025-12-26 21:53:45 +00:00
Chubby Granny Chaser
98dc20092a Merge pull request #1902 from hydralauncher/feat/LBX-155
Feat: Notification Hub (WIP)
2025-12-26 21:50:39 +00:00
Chubby Granny Chaser
9faf34a976 Merge branch 'main' into feat/disabling-update-badges 2025-12-26 21:49:35 +00:00
Chubby Granny Chaser
d25ac69e74 Merge branch 'main' into feat/LBX-155 2025-12-26 21:49:10 +00:00
Chubby Granny Chaser
d3fb967229 Merge pull request #1895 from hydralauncher/feat/wrapped-in-profile
feat: add Wrapped 2025 view in profile
2025-12-26 21:48:38 +00:00
Moyasee
263d0be4e4 refactor: improve code formatting and logging consistency in download manager and buzzheavier services 2025-12-26 23:38:01 +02:00
Moyasee
0b4d31e482 refactor: update notification item components to use button elements for better accessibility 2025-12-26 23:37:35 +02:00
Moyase
a74b557d13 Merge branch 'main' into feat/LBX-155 2025-12-26 20:01:35 +02:00
Moyasee
cea5afc7f7 feat: enhance add friend modal with friend code display and copy functionality 2025-12-24 13:22:57 +02:00
Moyasee
dff8d02f3f refactor: remove unused userDetails variable from App component 2025-12-23 18:48:56 +02:00
Moyasee
8751e369da refactor: remove sync friend requests functionality and related components 2025-12-23 18:47:31 +02:00
Moyasee
45eaef23a9 fix: move the view all button in badges box to the bottom left 2025-12-23 16:52:04 +02:00
Moyasee
3c296fe721 feat: add all badges modal and enhance badges display in profile 2025-12-23 15:43:52 +02:00
Moyasee
1d1bbd2de5 fix: forcing unread styling for friend request received notification 2025-12-23 13:40:50 +02:00
Moyasee
246fc14b75 fix: update notification display logic and impove empty state styling 2025-12-23 13:11:02 +02:00
Nikolay Rovdo
387b3ebeac Merge branch 'main' into patch-1 2025-12-22 15:04:41 +01:00
Moyasee
6cd65d6239 feat: implement add friend modal and enhance friends box with add friend functionality 2025-12-18 22:41:28 +02:00
Moyasee
cf16c8245c refactor(modal): change title type to React.ReactNode for better flexibility 2025-12-16 15:21:46 +02:00
Moyasee
6257529297 feat: enhance keyboard accessibility for notification items and friends modal 2025-12-16 15:20:13 +02:00
Moyasee
b8352be274 feat: add local notifications management and UI integration 2025-12-16 15:18:21 +02:00
Chubby Granny Chaser
3f41f0f7ad Merge branch 'main' into feat/disabling-update-badges 2025-12-10 17:26:35 +00:00
Nikolay Rovdo
1545f42d17 Adding chocolatey publishing 2025-11-30 14:51:24 +01:00
Moyasee
2adc132c33 fix: removed void from main.ts 2025-11-15 16:57:44 +02:00
Moyasee
c4852b89f1 feat: checkbox to disable new game update badges 2025-11-15 16:46:02 +02:00
127 changed files with 6191 additions and 1880 deletions

View File

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

View File

@@ -57,6 +57,7 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -73,6 +74,7 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -54,9 +54,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -71,9 +72,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}

View File

@@ -10,6 +10,7 @@
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![chocolatey](https://img.shields.io/chocolatey/v/hydralauncher.svg)](https://community.chocolatey.org/packages/hydralauncher)
![Hydra Launcher Home Page](./docs/screenshot.png)

View File

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

View File

@@ -1,4 +1,5 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpDownloader:
def __init__(self):
@@ -11,12 +12,16 @@ class HttpDownloader:
)
)
def start_download(self, url: str, save_path: str, header: str, out: str = None):
def start_download(self, url: str, save_path: str, header, out: str = None):
if self.download:
self.aria2.resume([self.download])
else:
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
options = {"dir": save_path}
if header:
options["header"] = header
if out:
options["out"] = out
downloads = self.aria2.add(url, options=options)
self.download = downloads[0]
def pause_download(self):
@@ -32,7 +37,11 @@ class HttpDownloader:
if self.download == None:
return None
download = self.aria2.get_download(self.download.gid)
try:
download = self.aria2.get_download(self.download.gid)
except DownloadNotFound:
self.download = None
return None
response = {
'folderName': download.name,

View File

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

View File

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

View File

@@ -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",
@@ -174,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",
@@ -183,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",
@@ -396,6 +404,10 @@
"completed": "Completed",
"removed": "Not downloaded",
"cancel": "Cancel",
"cancel_download": "Cancel download?",
"cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.",
"keep_downloading": "No, keep downloading",
"yes_cancel": "Yes, cancel",
"filter": "Filter downloaded games",
"remove": "Remove",
"downloading_metadata": "Downloading metadata…",
@@ -558,6 +570,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",
@@ -585,7 +598,10 @@
"notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup"
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress"
},
"notifications": {
"download_complete": "Download complete",
@@ -662,6 +678,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",
@@ -672,12 +689,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",
@@ -701,8 +723,16 @@
"report_reason_other": "Other",
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
"replace_banner": "Replace banner",
"remove_banner": "Remove banner",
"remove_banner_modal_title": "Remove banner?",
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
"remove": "Remove",
"background_image_updated": "Background image updated",
"stats": "Stats",
"achievements": "achievements",
@@ -720,13 +750,10 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
"wrapped_2025": "Wrapped 2025"
},
"library": {
"library": "Library",
@@ -777,5 +804,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"
}
}

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar Sesión",
"friends": "Amigos",
"notifications": "Notificaciones",
"need_help": "¿Necesitás ayuda?",
"favorites": "Favoritos",
"playable_button_title": "Solo mostrar juegos que podés jugar en este momento",
@@ -115,6 +116,7 @@
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
"extracting": "Extrayendo {{title}}… ({{percentage}} completado)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Instalación completada",
"installation_complete_message": "Common redistributables instalados correctamente"
@@ -173,6 +175,7 @@
"repacks_modal_description": "Elegí el repack que querés descargar",
"select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
"download_now": "Descargar ahora",
"loading": "Cargando...",
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga",
"download_path": "Ruta de descarga",
@@ -182,6 +185,12 @@
"open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes",
"downloader": "Descargador",
"downloader_online": "En línea",
"downloader_not_configured": "Disponible pero no configurado",
"downloader_offline": "El enlace está fuera de línea",
"downloader_not_available": "No disponible",
"recommended": "Recomendado",
"go_to_settings": "Ir a Ajustes",
"select_executable": "Seleccionar",
"no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta",
@@ -200,6 +209,7 @@
"danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
"download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada",
"extracting": "Extrayendo",
"last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam",
@@ -394,6 +404,10 @@
"completed": "Completado",
"removed": "No descargado",
"cancel": "Cancelar",
"cancel_download": "¿Cancelar descarga?",
"cancel_download_description": "¿Estás seguro de que querés cancelar esta descarga? Todos los archivos descargados serán eliminados.",
"keep_downloading": "No, seguir descargando",
"yes_cancel": "Sí, cancelar",
"filter": "Filtrar juegos descargados",
"remove": "Remover",
"downloading_metadata": "Descargando metadatos…",
@@ -414,7 +428,13 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"delete_archive_title": "¿Querés eliminar {{fileName}}?",
"delete_archive_description": "El archivo se extrajo exitosamente y ya no es necesario.",
"yes": "Sí",
"no": "No",
"network": "RED",
"peak": "PICO"
},
"settings": {
"downloads_path": "Ruta de descarga",
@@ -538,6 +558,7 @@
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
"extract_files_by_default": "Extraer archivos por defecto después de descargar",
"enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
"enable_new_download_options_badges": "Mostrar badges de nuevas opciones de descarga",
"achievement_custom_notification_position": "Posición de notificación de logros",
"top-left": "Superior Izquierda",
"top-center": "Superior Centro",
@@ -564,20 +585,10 @@
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"change_achievement_sound": "Cambiar sonido de logro",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
"download_source_failed": "Error",
"download_source_matched": "Actualizado",
"download_source_matching": "Actualizando",
"download_source_no_information": "Sin información disponible",
"download_source_pending_matching": "Actualizando pronto",
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
"hydra_cloud": "Hydra Cloud",
"preview_sound": "Vista previa de sonido",
"remove_achievement_sound": "Eliminar sonido de logros",
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
"downloads": "Descargas",
"use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso"
},
"notifications": {
"download_complete": "Descarga completada",
@@ -651,6 +662,7 @@
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amistades",
"badges": "Insignias",
"friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario",
@@ -661,12 +673,17 @@
"ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud",
"undo_friendship": "Deshacer amistad",
"friendship_removed": "Amigo eliminado",
"request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "No tenés amistades añadidas",
"no_friends_yet": "Aún no has agregado ningún amigo",
"view_all": "Ver todo",
"load_more": "Cargar más",
"loading": "Cargando",
"pending": "Pendiente",
"no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie",
@@ -690,8 +707,16 @@
"report_reason_other": "Otros",
"profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…",
"change_banner": "Cambiar banner",
"replace_banner": "Reemplazar banner",
"remove_banner": "Eliminar banner",
"remove_banner_modal_title": "¿Eliminar banner?",
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
"remove": "Eliminar",
"background_image_updated": "Imagen de fondo actualizada",
"stats": "Estadísticas",
"achievements": "logros",
@@ -710,11 +735,11 @@
"amount_minutes_short": "{{amount}}m",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña"
},
@@ -767,5 +792,41 @@
"all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente",
"favorites": "Favoritos"
},
"notifications_page": {
"title": "Notificaciones",
"mark_all_as_read": "Marcar todo como leído",
"clear_all": "Limpiar todo",
"loading": "Cargando...",
"empty_title": "Sin notificaciones",
"empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.",
"empty_filter_description": "No hay notificaciones que coincidan con este filtro.",
"filter_all": "Todas",
"filter_unread": "No leídas",
"filter_friends": "Amigos",
"filter_badges": "Insignias",
"filter_upvotes": "Votos",
"filter_local": "Locales",
"load_more": "Cargar más",
"dismiss": "Descartar",
"accept": "Aceptar",
"refuse": "Rechazar",
"notification": "Notificación",
"friend_request_received_title": "¡Nueva solicitud de amistad!",
"friend_request_received_description": "{{displayName}} quiere ser tu amigo",
"friend_request_accepted_title": "¡Solicitud de amistad aceptada!",
"friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad",
"badge_received_title": "¡Obtuviste una nueva insignia!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!",
"review_upvote_description": "Tu reseña recibió {{count}} nuevos votos",
"marked_all_as_read": "Todas las notificaciones marcadas como leídas",
"failed_to_mark_as_read": "Error al marcar las notificaciones como leídas",
"cleared_all": "Todas las notificaciones eliminadas",
"failed_to_clear": "Error al eliminar las notificaciones",
"failed_to_load": "Error al cargar las notificaciones",
"failed_to_dismiss": "Error al descartar la notificación",
"friend_request_accepted": "Solicitud de amistad aceptada",
"friend_request_refused": "Solicitud de amistad rechazada"
}
}

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login",
"friends": "Amigos",
"notifications": "Notificações",
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos",
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
@@ -163,6 +164,7 @@
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download",
"loading": "Carregando...",
"no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download",
"download_path": "Diretório de download",
@@ -172,6 +174,12 @@
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"downloader": "Downloader",
"downloader_online": "Online",
"downloader_not_configured": "Disponível mas não configurado",
"downloader_offline": "Link está offline",
"downloader_not_available": "Não disponível",
"recommended": "Recomendado",
"go_to_settings": "Ir para Configurações",
"select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
@@ -362,6 +370,7 @@
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"vote_failed": "Falha ao registrar seu voto. Por favor, tente novamente.",
"rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar",
@@ -384,6 +393,10 @@
"completed": "Concluído",
"removed": "Cancelado",
"cancel": "Cancelar",
"cancel_download": "Cancelar download?",
"cancel_download_description": "Tem certeza de que deseja cancelar este download? Todos os arquivos baixados serão excluídos.",
"keep_downloading": "Não, continuar baixando",
"yes_cancel": "Sim, cancelar",
"filter": "Filtrar jogos baixados",
"remove": "Remover",
"downloading_metadata": "Baixando metadados…",
@@ -457,6 +470,7 @@
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas",
"removed_all_download_sources": "Todas as fontes de download removidas",
"cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo",
"title_confirmation_delete_all_sources": "Remover todas as fontes de download",
@@ -482,6 +496,7 @@
"blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"hydra_cloud": "Hydra Cloud",
"launch_minimized": "Iniciar o Hydra minimizado",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download",
@@ -544,6 +559,7 @@
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download",
"enable_steam_achievements": "Habilitar busca por conquistas da Steam",
"enable_new_download_options_badges": "Mostrar badges de novas opções de download",
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
"top-left": "Superior esquerdo",
"top-center": "Superior central",
@@ -561,6 +577,9 @@
"test_notification": "Testar notificação",
"achievement_sound_volume": "Volume do som de conquista",
"select_achievement_sound": "Selecionar som de conquista",
"change_achievement_sound": "Alterar som de conquista",
"remove_achievement_sound": "Remover som de conquista",
"preview_sound": "Reproduzir som",
"select": "Selecionar",
"preview": "Reproduzir",
"remove": "Remover",
@@ -568,7 +587,10 @@
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo"
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo",
"downloads": "Downloads",
"use_native_http_downloader": "Usar downloader HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "Não é possível alterar esta configuração enquanto um download estiver em andamento"
},
"notifications": {
"download_complete": "Download concluído",
@@ -654,6 +676,7 @@
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos",
"badges": "Insígnias",
"add": "Adicionar",
"sending": "Enviando",
"friends_list": "Lista de amigos",
@@ -666,12 +689,17 @@
"ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade",
"friendship_removed": "Amigo removido",
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados",
"no_friends_yet": "Você ainda não adicionou nenhum amigo",
"view_all": "Ver todos",
"load_more": "Carregar mais",
"loading": "Carregando",
"pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
@@ -695,8 +723,16 @@
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…",
"change_banner": "Alterar banner",
"replace_banner": "Substituir banner",
"remove_banner": "Remover banner",
"remove_banner_modal_title": "Remover banner?",
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
"remove": "Remover",
"background_image_updated": "Imagem de fundo salva",
"stats": "Estatísticas",
"achievements": "conquistas",
@@ -720,10 +756,10 @@
"achievements_earned": "Conquistas recebidas",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação"
},
@@ -776,5 +812,41 @@
"all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente",
"favorites": "Favoritos"
},
"notifications_page": {
"title": "Notificações",
"mark_all_as_read": "Marcar todas como lidas",
"clear_all": "Limpar todas",
"loading": "Carregando...",
"empty_title": "Sem notificações",
"empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.",
"empty_filter_description": "Nenhuma notificação corresponde a este filtro.",
"filter_all": "Todas",
"filter_unread": "Não lidas",
"filter_friends": "Amigos",
"filter_badges": "Insígnias",
"filter_upvotes": "Votos",
"filter_local": "Locais",
"load_more": "Carregar mais",
"dismiss": "Descartar",
"accept": "Aceitar",
"refuse": "Recusar",
"notification": "Notificação",
"friend_request_received_title": "Nova solicitação de amizade!",
"friend_request_received_description": "{{displayName}} quer ser seu amigo",
"friend_request_accepted_title": "Solicitação de amizade aceita!",
"friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade",
"badge_received_title": "Você recebeu uma nova insígnia!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!",
"review_upvote_description": "Sua avaliação recebeu {{count}} novos votos",
"marked_all_as_read": "Todas as notificações marcadas como lidas",
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
"cleared_all": "Todas as notificações limpas",
"failed_to_clear": "Falha ao limpar notificações",
"failed_to_load": "Falha ao carregar notificações",
"failed_to_dismiss": "Falha ao descartar notificação",
"friend_request_accepted": "Solicitação de amizade aceita",
"friend_request_refused": "Solicitação de amizade recusada"
}
}

View File

@@ -508,7 +508,7 @@
"show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
"animated_profile_banner": "Banner animado no perfil",
"cloud_saving": "Progresso dos jogos na nuvem",
"hydra_cloud_feature_found": "Descubriste uma funcionalidade Hydra Cloud!",
"hydra_cloud_feature_found": "Descobriste uma funcionalidade Hydra Cloud!",
"learn_more": "Saber mais"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const clearAllLocalNotifications = async () => {
await LocalNotificationManager.clearAll();
};
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);

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

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotificationsCount = async () => {
return LocalNotificationManager.getUnreadCount();
};
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotifications = async () => {
return LocalNotificationManager.getNotifications();
};
registerEvent("getLocalNotifications", getLocalNotifications);

View File

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

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markAllLocalNotificationsRead = async () => {
await LocalNotificationManager.markAllAsRead();
};
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);

View File

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

View File

@@ -1,4 +1,3 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

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

View File

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

View File

@@ -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)(

View File

@@ -8,3 +8,4 @@ export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";
export * from "./local-notifications";

View File

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

View 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",
});

View File

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

View File

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

View File

@@ -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(

View File

@@ -4,10 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types";
import {
GofileApi,
QiwiApi,
DatanodesApi,
MediafireApi,
PixelDrainApi,
VikingFileApi,
RootzApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -17,7 +18,7 @@ import {
} from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid";
import path from "path";
import path from "node:path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
@@ -25,25 +26,41 @@ import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
import { JsHttpDownloader } from "./js-http-downloader";
export class DownloadManager {
private static downloadingGameId: string | null = null;
private static jsDownloader: JsHttpDownloader | null = null;
private static usingJsDownloader = false;
private static isPreparingDownload = false;
private static extractFilename(url: string, originalUrl?: string): string | undefined {
if (originalUrl?.includes('#')) {
const hashPart = originalUrl.split('#')[1];
if (hashPart && !hashPart.startsWith('http')) return hashPart;
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')) 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 filename = urlObj.pathname.split('/').pop();
if (filename?.length) return filename;
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch {
// Invalid URL
}
@@ -52,15 +69,56 @@ export class DownloadManager {
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[<>:"/\\|?*]/g, '_');
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
}
private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) {
const filename = this.extractFilename(directUrl, originalUrl);
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
private static resolveFilename(
resumingFilename: string | undefined,
originalUrl: string,
downloadUrl: string
): string | undefined {
if (resumingFilename) return resumingFilename;
const extracted =
this.extractFilename(originalUrl, downloadUrl) ||
this.extractFilename(downloadUrl);
return extracted ? this.sanitizeFilename(extracted) : undefined;
}
private static buildDownloadOptions(
url: string,
savePath: string,
filename: string | undefined,
headers?: Record<string, string>
) {
return {
url,
savePath,
filename,
headers,
};
}
private static createDownloadPayload(
directUrl: string,
originalUrl: string,
downloadId: string,
savePath: string
) {
const filename =
this.extractFilename(originalUrl, directUrl) ||
this.extractFilename(directUrl);
const sanitizedFilename = filename
? this.sanitizeFilename(filename)
: undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
);
}
return {
@@ -73,6 +131,19 @@ export class DownloadManager {
};
}
private static async shouldUseJsDownloader(): Promise<boolean> {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
// Default to true - native HTTP downloader is enabled by default (opt-out)
return userPreferences?.useNativeHttpDownloader ?? true;
}
private static isHttpDownloader(downloader: Downloader): boolean {
return downloader !== Downloader.Torrent;
}
public static async startRPC(
download?: Download,
downloadsToSeed?: Download[]
@@ -97,8 +168,90 @@ export class DownloadManager {
}
}
private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
private static async getDownloadStatusFromJs(): Promise<DownloadProgress | null> {
if (!this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
// Return a "preparing" status while fetching download options
if (this.isPreparingDownload) {
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed: 0,
timeRemaining: -1,
isDownloadingMetadata: true, // Use this to indicate "preparing"
isCheckingFiles: false,
progress: 0,
gameId: downloadId,
download,
};
} catch {
return null;
}
}
if (!this.jsDownloader) return null;
const status = this.jsDownloader.getDownloadStatus();
if (!status) return null;
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } =
status;
// Only update fileSize in database if we actually know it (> 0)
// Otherwise keep the existing value to avoid showing "0 B"
const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize;
const updatedDownload = {
...download,
bytesDownloaded,
fileSize: effectiveFileSize,
progress,
folderName,
status:
status.status === "complete"
? ("complete" as const)
: ("active" as const),
};
if (status.status === "active" || status.status === "complete") {
await downloadsSublevel.put(downloadId, updatedDownload);
}
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed,
timeRemaining: calculateETA(
effectiveFileSize ?? 0,
bytesDownloaded,
downloadSpeed
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: downloadId,
download: updatedDownload,
};
} catch (err) {
logger.error("[DownloadManager] Error getting JS download status:", err);
return null;
}
}
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
if (response.data === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
@@ -114,7 +267,8 @@ export class DownloadManager {
status,
} = response.data;
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
const download = await downloadsSublevel.get(downloadId);
@@ -143,91 +297,141 @@ export class DownloadManager {
gameId: downloadId,
download,
} as DownloadProgress;
} catch (err) {
} catch {
return null;
}
}
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
if (this.usingJsDownloader) {
return this.getDownloadStatusFromJs();
}
return this.getDownloadStatusFromRpc();
}
public static async watchDownloads() {
const status = await this.getDownloadStatus();
if (!status) return;
if (status) {
const { gameId, progress } = status;
const { gameId, progress } = status;
const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
const [download, game] = await Promise.all([
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
if (!download || !game) return;
if (!download || !game) return;
this.sendProgressUpdate(progress, status, game);
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
if (progress === 1) {
await this.handleDownloadCompletion(download, game, gameId);
}
}
private static sendProgressUpdate(
progress: number,
status: DownloadProgress,
game: any
) {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
structuredClone({ ...status, game })
);
}
}
private static async handleDownloadCompletion(
download: Download,
game: any,
gameId: string
) {
publishDownloadCompleteNotification(game);
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
await this.updateDownloadStatus(
download,
gameId,
userPreferences?.seedAfterDownloadComplete
);
if (download.automaticallyExtract) {
this.handleExtraction(download, game);
}
await this.processNextQueuedDownload();
}
private static async updateDownloadStatus(
download: Download,
gameId: string,
shouldSeed?: boolean
) {
const shouldExtract = download.automaticallyExtract;
if (shouldSeed && download.downloader === Downloader.Torrent) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
});
} else {
await downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
extracting: shouldExtract,
});
this.cancelDownload(gameId);
}
}
private static handleExtraction(download: Download, game: any) {
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
)
.then(() => gameFilesManager.setExtractionComplete());
}
}
private static async processNextQueuedDownload() {
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
);
}
const [nextItemOnQueue] = downloads;
const shouldExtract = download.automaticallyExtract;
if (progress === 1 && download) {
publishDownloadCompleteNotification(game);
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
});
} else {
await downloadsSublevel.put(gameId, {
...download,
status: "complete",
shouldSeed: false,
queued: false,
extracting: shouldExtract,
});
this.cancelDownload(gameId);
}
if (shouldExtract) {
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) {
gameFilesManager.extractDownloadedFile();
} else {
gameFilesManager
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
.then(() => gameFilesManager.setExtractionComplete());
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) => sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
));
const [nextItemOnQueue] = downloads;
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = null;
}
}
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = null;
this.usingJsDownloader = false;
this.jsDownloader = null;
}
}
@@ -245,7 +449,9 @@ export class DownloadManager {
if (!download) return;
const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName));
const totalSize = await getDirSize(
path.join(download.downloadPath, status.folderName)
);
if (totalSize < status.fileSize) {
await this.cancelDownload(status.gameId);
@@ -265,9 +471,17 @@ export class DownloadManager {
}
static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
.catch(() => {});
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Pausing JS download");
this.jsDownloader.pauseDownload();
} else {
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
}
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
@@ -280,14 +494,23 @@ export class DownloadManager {
}
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Cancelling JS download");
this.jsDownloader.cancelDownload();
this.jsDownloader = null;
this.usingJsDownloader = false;
} else if (!this.isPreparingDownload) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
}
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingJsDownloader = false;
}
}
@@ -307,6 +530,241 @@ export class DownloadManager {
});
}
private static async getJsDownloadOptions(download: Download): Promise<{
url: string;
savePath: string;
filename?: string;
headers?: Record<string, string>;
} | null> {
const resumingFilename = download.folderName || undefined;
switch (download.downloader) {
case Downloader.Gofile:
return this.getGofileDownloadOptions(download, resumingFilename);
case Downloader.PixelDrain:
return this.getPixelDrainDownloadOptions(download, resumingFilename);
case Downloader.Datanodes:
return this.getDatanodesDownloadOptions(download, resumingFilename);
case Downloader.Buzzheavier:
return this.getBuzzheavierDownloadOptions(download, resumingFilename);
case Downloader.FuckingFast:
return this.getFuckingFastDownloadOptions(download, resumingFilename);
case Downloader.Mediafire:
return this.getMediafireDownloadOptions(download, resumingFilename);
case Downloader.RealDebrid:
return this.getRealDebridDownloadOptions(download, resumingFilename);
case Downloader.TorBox:
return this.getTorBoxDownloadOptions(download, resumingFilename);
case Downloader.Hydra:
return this.getHydraDownloadOptions(download, resumingFilename);
case Downloader.VikingFile:
return this.getVikingFileDownloadOptions(download, resumingFilename);
case Downloader.Rootz:
return this.getRootzDownloadOptions(download, resumingFilename);
default:
return null;
}
}
private static async getGofileDownloadOptions(
download: Download,
resumingFilename?: string
) {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadLink
);
return this.buildDownloadOptions(
downloadLink,
download.downloadPath,
filename,
{ Cookie: `accountToken=${token}` }
);
}
private static async getPixelDrainDownloadOptions(
download: Download,
resumingFilename?: string
) {
const id = download.uri.split("/").pop();
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getDatanodesDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getBuzzheavierDownloadOptions(
download: Download,
resumingFilename?: string
) {
logger.log(
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
);
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
directUrl
);
return this.buildDownloadOptions(
directUrl,
download.downloadPath,
filename
);
}
private static async getFuckingFastDownloadOptions(
download: Download,
resumingFilename?: string
) {
logger.log(
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
);
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
directUrl
);
return this.buildDownloadOptions(
directUrl,
download.downloadPath,
filename
);
}
private static async getMediafireDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getRealDebridDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getTorBoxDownloadOptions(
download: Download,
resumingFilename?: string
) {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return null;
return this.buildDownloadOptions(
url,
download.downloadPath,
resumingFilename || name
);
}
private static async getHydraDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getVikingFileDownloadOptions(
download: Download,
resumingFilename?: string
) {
logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
);
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getRootzDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getDownloadPayload(download: Download) {
const downloadId = levelKeys.game(download.shop, download.objectId);
@@ -338,15 +796,6 @@ export class DownloadManager {
save_path: download.downloadPath,
};
}
case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return {
@@ -357,24 +806,44 @@ export class DownloadManager {
};
}
case Downloader.Buzzheavier: {
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
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);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
logger.error(
`[DownloadManager] Error processing Buzzheavier download:`,
error
);
throw error;
}
}
case Downloader.FuckingFast: {
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
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);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
logger.error(
`[DownloadManager] Error processing FuckingFast download:`,
error
);
throw error;
}
}
@@ -419,7 +888,9 @@ export class DownloadManager {
};
}
case Downloader.Hydra: {
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {
@@ -430,12 +901,75 @@ export class DownloadManager {
allow_multiple_connections: true,
};
}
case Downloader.VikingFile: {
logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
);
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
return this.createDownloadPayload(
downloadUrl,
download.uri,
downloadId,
download.downloadPath
);
}
case Downloader.Rootz: {
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
default:
return undefined;
}
}
static async startDownload(download: Download) {
const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
const useJsDownloader = await this.shouldUseJsDownloader();
const isHttp = this.isHttpDownloader(download.downloader);
const downloadId = levelKeys.game(download.shop, download.objectId);
if (useJsDownloader && isHttp) {
logger.log("[DownloadManager] Using JS HTTP downloader");
// Set preparing state immediately so UI knows download is starting
this.downloadingGameId = downloadId;
this.isPreparingDownload = true;
this.usingJsDownloader = true;
try {
const options = await this.getJsDownloadOptions(download);
if (!options) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw new Error("Failed to get download options for JS downloader");
}
this.jsDownloader = new JsHttpDownloader();
this.isPreparingDownload = false;
this.jsDownloader.startDownload(options).catch((err) => {
logger.error("[DownloadManager] JS download error:", err);
this.usingJsDownloader = false;
this.jsDownloader = null;
});
} catch (err) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw err;
}
} else {
logger.log("[DownloadManager] Using Python RPC downloader");
const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = downloadId;
this.usingJsDownloader = false;
}
}
}

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import axios from "axios";
import http from "node:http";
import https from "node:https";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
@@ -21,11 +23,19 @@ export class BuzzheavierApi {
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`);
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`;
@@ -41,12 +51,20 @@ export class BuzzheavierApi {
validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
});
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`);
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."
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import axios from "axios";
import { logger } from "../logger";
interface UnlockResponse {
link: string;
hoster: string;
}
export class VikingFileApi {
public static async getDownloadUrl(uri: string): Promise<string> {
const unlockResponse = await axios.post<UnlockResponse>(
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
{ url: uri }
);
if (!unlockResponse.data.link) {
throw new Error("Failed to unlock VikingFile URL");
}
const redirectUrl = unlockResponse.data.link;
try {
const redirectResponse = await axios.head(redirectUrl, {
maxRedirects: 0,
validateStatus: (status) =>
status === 301 || status === 302 || status === 200,
});
if (
redirectResponse.headers.location ||
redirectResponse.status === 301 ||
redirectResponse.status === 302
) {
return redirectResponse.headers.location || redirectUrl;
}
return redirectUrl;
} catch (error) {
logger.error(
`[VikingFile] Error following redirect, using redirect URL:`,
error
);
return redirectUrl;
}
}
}

View File

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

View File

@@ -20,3 +20,4 @@ export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
export * from "./download-sources-checker";
export * from "./notifications/local-notifications";

View File

@@ -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: {

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import type {
GameAchievement,
Theme,
FriendRequestSync,
NotificationSync,
ShortcutLocation,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
@@ -205,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId),
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
openGameExecutablePath: (shop: GameShop, objectId: string) =>
@@ -497,7 +500,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,
@@ -507,6 +509,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),
@@ -550,6 +561,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,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -23,7 +23,6 @@ import {
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";
@@ -53,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,
@@ -120,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) {
@@ -131,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"));
}
});
@@ -211,6 +233,7 @@ export function App() {
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
workwondersRef.current?.notifyUrlChange();
}, [location.pathname, location.search]);
useEffect(() => {
@@ -305,15 +328,6 @@ export function App() {
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main>
<Sidebar />

View File

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

View File

@@ -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");
@@ -223,21 +224,6 @@ export function Header() {
setActiveIndex(-1);
};
useEffect(() => {
const prevPath = sessionStorage.getItem("prevPath");
const currentPath = location.pathname;
if (
prevPath?.startsWith("/catalogue") &&
!currentPath.startsWith("/catalogue") &&
catalogueSearchValue
) {
dispatch(setFilters({ title: "" }));
}
sessionStorage.setItem("prevPath", currentPath);
}, [location.pathname, catalogueSearchValue, dispatch]);
useEffect(() => {
if (!isDropdownVisible) return;
@@ -323,7 +309,8 @@ export function Header() {
<SearchDropdown
visible={
isDropdownVisible &&
(historyItems.length > 0 ||
(searchValue.trim().length > 0 ||
historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,23 +19,173 @@
color: globals.$body-color;
}
&__downloaders {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
&__downloaders-list-wrapper {
border: 1px solid globals.$border-color;
overflow: hidden;
background-color: globals.$dark-background-color;
}
&__downloader-option {
position: relative;
&__downloaders-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&:only-child {
grid-column: 1 / -1;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
}
&__downloader-icon {
position: absolute;
left: calc(globals.$spacing-unit * 2);
&__downloader-item {
display: flex;
align-items: center;
gap: 8px;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
border: 1px solid transparent;
border-bottom: 1px solid globals.$border-color;
border-radius: 0;
background-color: transparent;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
color: globals.$body-color;
font-size: 14px;
text-align: left;
height: 48px;
box-sizing: border-box;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&--selected {
background-color: rgba(255, 255, 255, 0.08);
}
&--last {
border-bottom: none;
}
&:disabled {
cursor: default;
&:hover {
background-color: transparent;
}
.download-settings-modal__downloader-name {
opacity: 0.5;
}
.download-settings-modal__availability-indicator-wrapper {
opacity: 0.5;
}
}
}
&__downloader-item-wrapper {
display: flex;
flex-direction: column;
}
&__check-icon {
color: white;
flex-shrink: 0;
}
&__check-icon-wrapper {
margin-left: auto;
display: flex;
align-items: center;
width: 20px;
height: 20px;
justify-content: center;
flex-shrink: 0;
}
&__recommendation-badge {
margin-left: auto;
display: flex;
align-items: center;
height: 20px;
justify-content: center;
flex-shrink: 0;
.badge {
padding: 2px 6px;
font-size: 10px;
line-height: 1.2;
height: 16px;
display: flex;
align-items: center;
white-space: nowrap;
}
}
&__availability-indicator-wrapper {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__availability-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--available {
background-color: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
&--unavailable {
background-color: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
}
&--not-present {
background-color: #6b7280;
box-shadow: 0 0 6px rgba(107, 114, 128, 0.5);
}
&--warning {
background-color: #eab308;
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
&__availability-indicator--pulsating {
animation: pulse 2s ease-in-out infinite;
}
&__path-error {
@@ -49,4 +199,17 @@
&__change-path-button {
align-self: flex-end;
}
&__loading-spinner {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,17 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Badge,
Button,
CheckboxField,
Link,
Modal,
TextField,
} from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import { motion } from "framer-motion";
import { Tooltip } from "react-tooltip";
import { RealDebridInfoModal } from "./real-debrid-info-modal";
import "./download-settings-modal.scss";
export interface DownloadSettingsModalProps {
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null
);
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature();
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
}
}, [visible, checkFolderWritePermission, selectedPath]);
const downloaders = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.uris]);
const downloadOptions = useMemo(() => {
const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
const downloaderMap = new Map<
Downloader,
{ hasAvailable: boolean; hasUnavailable: boolean }
>();
if (repack) {
for (const uri of repack.uris) {
const uriDownloaders = getDownloadersForUri(uri);
const isAvailable = !unavailableUrisSet.has(uri);
for (const downloader of uriDownloaders) {
const existing = downloaderMap.get(downloader);
if (existing) {
existing.hasAvailable = existing.hasAvailable || isAvailable;
existing.hasUnavailable = existing.hasUnavailable || !isAvailable;
} else {
downloaderMap.set(downloader, {
hasAvailable: isAvailable,
hasUnavailable: !isAvailable,
});
}
}
}
}
const allDownloaders = Object.values(Downloader).filter(
(value) => typeof value === "number"
) as Downloader[];
const getDownloaderPriority = (option: {
isAvailable: boolean;
canHandle: boolean;
isAvailableButNotConfigured: boolean;
}) => {
if (option.isAvailable) return 0;
if (option.canHandle && !option.isAvailableButNotConfigured) return 1;
if (option.isAvailableButNotConfigured) return 2;
return 3;
};
return allDownloaders
.filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus
.map((downloader) => {
const status = downloaderMap.get(downloader);
const canHandle = status !== undefined;
const isAvailable = status?.hasAvailable ?? false;
let isConfigured = true;
if (downloader === Downloader.RealDebrid) {
isConfigured = !!userPreferences?.realDebridApiToken;
} else if (downloader === Downloader.TorBox) {
isConfigured = !!userPreferences?.torBoxApiToken;
}
// } else if (downloader === Downloader.Hydra) {
// isConfigured = isFeatureEnabled(Feature.Nimbus);
// }
const isAvailableButNotConfigured =
isAvailable && !isConfigured && canHandle;
return {
downloader,
isAvailable: isAvailable && isConfigured,
canHandle,
isAvailableButNotConfigured,
};
})
.sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b));
}, [
repack,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
isFeatureEnabled,
Feature,
]);
const getDefaultDownloader = useCallback(
(availableDownloaders: Downloader[]) => {
if (availableDownloaders.length === 0) return null;
if (availableDownloaders.includes(Downloader.Hydra)) {
return Downloader.Hydra;
}
if (availableDownloaders.includes(Downloader.RealDebrid)) {
return Downloader.RealDebrid;
}
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
}
const filteredDownloaders = downloaders.filter((downloader) => {
if (downloader === Downloader.RealDebrid)
return userPreferences?.realDebridApiToken;
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
});
const availableDownloaders = downloadOptions
.filter((option) => option.isAvailable)
.map((option) => option.downloader);
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
}, [
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
@@ -186,33 +252,144 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => {
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&
!isFeatureEnabled(Feature.Nimbus));
<div className="download-settings-modal__downloaders-list-wrapper">
<div className="download-settings-modal__downloaders-list">
{downloadOptions.map((option, index) => {
const isSelected = selectedDownloader === option.downloader;
const tooltipId = `availability-indicator-${option.downloader}`;
const isLastItem = index === downloadOptions.length - 1;
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
const Indicator = option.isAvailable ? motion.span : "span";
const isDisabled =
!option.canHandle ||
(!option.isAvailable && !option.isAvailableButNotConfigured);
const getAvailabilityIndicator = () => {
if (option.isAvailable) {
return (
<Indicator
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--available download-settings-modal__availability-indicator--pulsating`}
animate={{
scale: [1, 1.1, 1],
opacity: [1, 0.7, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_online")}
/>
);
}
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
if (option.isAvailableButNotConfigured) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_configured")}
/>
);
}
if (option.canHandle) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--unavailable`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_offline")}
/>
);
}
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_available")}
/>
);
};
const getRightContent = () => {
if (isSelected) {
return (
<motion.div
className="download-settings-modal__check-icon-wrapper"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<CheckCircleFillIcon
size={16}
className="download-settings-modal__check-icon"
/>
</motion.div>
);
}
if (
option.downloader === Downloader.RealDebrid &&
option.canHandle
) {
return (
<div className="download-settings-modal__recommendation-badge">
<Badge>{t("recommended")}</Badge>
</div>
);
}
return null;
};
return (
<div
key={option.downloader}
className="download-settings-modal__downloader-item-wrapper"
>
<button
type="button"
className={`download-settings-modal__downloader-item ${
isSelected
? "download-settings-modal__downloader-item--selected"
: ""
} ${
isLastItem
? "download-settings-modal__downloader-item--last"
: ""
}`}
disabled={isDisabled}
onClick={() => {
if (
option.downloader === Downloader.RealDebrid &&
option.isAvailableButNotConfigured
) {
setShowRealDebridModal(true);
} else {
setSelectedDownloader(option.downloader);
}
}}
>
<span className="download-settings-modal__downloader-name">
{DOWNLOADER_NAME[option.downloader]}
</span>
<div className="download-settings-modal__availability-indicator-wrapper">
{getAvailabilityIndicator()}
</div>
<Tooltip id={tooltipId} />
{getRightContent()}
</button>
</div>
);
})}
</div>
</div>
</div>
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
disabled={
downloadStarting ||
selectedDownloader === null ||
!hasWritePermission
!hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
}
>
<DownloadIcon />
{t("download_now")}
{downloadStarting ? (
<>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
</Button>
</div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal>
);
}

View File

@@ -0,0 +1,36 @@
@use "../../../scss/globals.scss";
.real-debrid-info-modal {
&__content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2.5);
width: 100%;
max-width: 500px;
}
&__description-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__description {
margin: 0;
color: globals.$body-color;
line-height: 1.6;
}
&__create-account {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
color: #c0c1c7;
text-decoration: underline;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button, Link, Modal } from "@renderer/components";
import { LinkExternalIcon } from "@primer/octicons-react";
import "./real-debrid-info-modal.scss";
const realDebridReferralId = import.meta.env
.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID;
const REAL_DEBRID_URL = realDebridReferralId
? `https://real-debrid.com/?id=${realDebridReferralId}`
: "https://real-debrid.com";
export interface RealDebridInfoModalProps {
visible: boolean;
onClose: () => void;
}
export function RealDebridInfoModal({
visible,
onClose,
}: Readonly<RealDebridInfoModalProps>) {
const { t } = useTranslation("game_details");
const { t: tSettings } = useTranslation("settings");
const navigate = useNavigate();
return (
<Modal
visible={visible}
title={tSettings("enable_real_debrid")}
onClose={onClose}
>
<div className="real-debrid-info-modal__content">
<div className="real-debrid-info-modal__description-container">
<p className="real-debrid-info-modal__description">
{tSettings("real_debrid_description")}
</p>
<Link
to={REAL_DEBRID_URL}
className="real-debrid-info-modal__create-account"
>
<LinkExternalIcon />
{tSettings("create_real_debrid_account")}
</Link>
</div>
<Button
onClick={() => {
onClose();
navigate("/settings?tab=4");
}}
>
{t("go_to_settings")}
</Button>
</div>
</Modal>
);
}

View File

@@ -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 (
@@ -363,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 && (

View File

@@ -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 {

View File

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

View File

@@ -0,0 +1,103 @@
import { useCallback } from "react";
import {
XIcon,
DownloadIcon,
PackageIcon,
SyncIcon,
TrophyIcon,
ClockIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDate } from "@renderer/hooks";
import cn from "classnames";
import type { LocalNotification } from "@types";
import "./notification-item.scss";
interface LocalNotificationItemProps {
notification: LocalNotification;
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
}
export function LocalNotificationItem({
notification,
onDismiss,
onMarkAsRead,
}: Readonly<LocalNotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(notification.url);
}
}, [notification, onMarkAsRead, navigate]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getIcon = () => {
switch (notification.type) {
case "DOWNLOAD_COMPLETE":
return <DownloadIcon size={24} />;
case "EXTRACTION_COMPLETE":
return <PackageIcon size={24} />;
case "UPDATE_AVAILABLE":
return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />;
default:
return <DownloadIcon size={24} />;
}
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread": !notification.isRead,
})}
onClick={handleClick}
>
<div className="notification-item__picture">
{notification.pictureUrl ? (
<img src={notification.pictureUrl} alt="" />
) : (
getIcon()
)}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{notification.title}</span>
<span className="notification-item__description">
{notification.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
</button>
);
}

View File

@@ -0,0 +1,127 @@
@use "../../scss/globals.scss";
.notification-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
transition: all ease 0.2s;
position: relative;
opacity: 0.4;
width: 100%;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
opacity: 0.6;
}
&--unread {
border-left: 3px solid globals.$brand-teal;
opacity: 1;
&:hover {
opacity: 1;
}
.notification-item__title {
color: #fff;
}
}
&__picture {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
color: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
svg {
color: inherit;
}
}
&__badge-picture {
border-radius: 8px;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__review-picture {
color: #f5a623;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
&__description {
font-size: globals.$small-font-size;
color: globals.$body-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: globals.$small-font-size;
color: rgba(255, 255, 255, 0.5);
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
flex-shrink: 0;
}
&__dismiss {
position: absolute;
top: calc(globals.$spacing-unit / 2);
right: calc(globals.$spacing-unit / 2);
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
}
}

View File

@@ -0,0 +1,228 @@
import { useCallback, useMemo } from "react";
import {
XIcon,
PersonIcon,
ClockIcon,
StarFillIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@renderer/components";
import { useDate, useUserDetails } from "@renderer/hooks";
import cn from "classnames";
import type { Notification, Badge } from "@types";
import "./notification-item.scss";
const parseNotificationUrl = (notificationUrl: string): string => {
const url = new URL(notificationUrl, "http://localhost");
const userId = url.searchParams.get("userId");
const badgeName = url.searchParams.get("name");
const gameTitle = url.searchParams.get("title");
const showReviews = url.searchParams.get("reviews");
if (url.pathname === "/profile" && userId) {
return `/profile/${userId}`;
}
if (url.pathname === "/badges" && badgeName) {
return `/badges/${badgeName}`;
}
if (url.pathname.startsWith("/game/")) {
const params = new URLSearchParams();
if (gameTitle) params.set("title", gameTitle);
if (showReviews) params.set("reviews", showReviews);
const queryString = params.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}
return notificationUrl;
};
interface NotificationItemProps {
notification: Notification;
badges: Badge[];
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
onAcceptFriendRequest?: (senderId: string) => void;
onRefuseFriendRequest?: (senderId: string) => void;
}
export function NotificationItem({
notification,
badges,
onDismiss,
onMarkAsRead,
onAcceptFriendRequest,
onRefuseFriendRequest,
}: Readonly<NotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const { updateFriendRequestState } = useUserDetails();
const badge = useMemo(() => {
if (notification.type !== "BADGE_RECEIVED") return null;
return badges.find((b) => b.name === notification.variables.badgeName);
}, [notification, badges]);
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(parseNotificationUrl(notification.url));
}
}, [notification, onMarkAsRead, navigate]);
const handleAccept = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "ACCEPTED");
onAcceptFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
);
const handleRefuse = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "REFUSED");
onRefuseFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getNotificationContent = () => {
switch (notification.type) {
case "FRIEND_REQUEST_RECEIVED":
return {
title: t("friend_request_received_title"),
description: t("friend_request_received_description", {
displayName: notification.variables.senderDisplayName,
}),
showActions: true,
};
case "FRIEND_REQUEST_ACCEPTED":
return {
title: t("friend_request_accepted_title"),
description: t("friend_request_accepted_description", {
displayName: notification.variables.accepterDisplayName,
}),
showActions: false,
};
case "BADGE_RECEIVED":
return {
title: t("badge_received_title"),
description: badge?.description || notification.variables.badgeName,
showActions: false,
};
case "REVIEW_UPVOTE":
return {
title: t("review_upvote_title", {
gameTitle: notification.variables.gameTitle,
}),
description: t("review_upvote_description", {
count: Number.parseInt(
notification.variables.upvoteCount || "1",
10
),
}),
showActions: false,
};
default:
return {
title: t("notification"),
description: "",
showActions: false,
};
}
};
const content = getNotificationContent();
const isBadge = notification.type === "BADGE_RECEIVED";
const isReview = notification.type === "REVIEW_UPVOTE";
const getIcon = () => {
if (notification.pictureUrl) {
return <img src={notification.pictureUrl} alt="" />;
}
if (isReview) {
return <StarFillIcon size={24} />;
}
return <PersonIcon size={24} />;
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread":
!notification.isRead ||
notification.type === "FRIEND_REQUEST_RECEIVED",
})}
onClick={handleClick}
>
<div
className={cn("notification-item__picture", {
"notification-item__badge-picture": isBadge,
"notification-item__review-picture": isReview,
})}
>
{getIcon()}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{content.title}</span>
<span className="notification-item__description">
{content.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
{content.showActions &&
notification.type === "FRIEND_REQUEST_RECEIVED" && (
<div className="notification-item__actions">
<Button theme="primary" onClick={handleAccept}>
{t("accept")}
</Button>
<Button theme="outline" onClick={handleRefuse}>
{t("refuse")}
</Button>
</div>
)}
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
)}
</button>
);
}

View File

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

View File

@@ -0,0 +1,554 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@renderer/components";
import { useAppDispatch, useToast, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { logger } from "@renderer/logger";
import { NotificationItem } from "./notification-item";
import { LocalNotificationItem } from "./local-notification-item";
import type {
Notification,
LocalNotification,
NotificationsResponse,
MergedNotification,
Badge,
} from "@types";
import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast();
const { userDetails } = useUserDetails();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setHeaderTitle(t("title")));
}, [dispatch, t]);
const [apiNotifications, setApiNotifications] = useState<Notification[]>([]);
const [localNotifications, setLocalNotifications] = useState<
LocalNotification[]
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
skip: 0,
});
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => {
try {
const notifications = await window.electron.getLocalNotifications();
setLocalNotifications(notifications);
} catch (error) {
logger.error("Failed to fetch local notifications", error);
}
}, []);
const fetchBadges = useCallback(async () => {
try {
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({ locale: language });
const badgesResponse = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,
{ needsAuth: false }
);
setBadges(badgesResponse);
} catch (error) {
logger.error("Failed to fetch badges", error);
}
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return;
try {
setIsLoading(true);
const response =
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: filterParam, take: 20, skip },
needsAuth: true,
}
);
logger.log("Notifications API response:", response);
if (append) {
setApiNotifications((prev) => [...prev, ...response.notifications]);
} else {
setApiNotifications(response.notifications);
}
setPagination({
total: response.pagination.total,
hasMore: response.pagination.hasMore,
skip: response.pagination.skip + response.pagination.take,
});
} catch (error) {
logger.error("Failed to fetch API notifications", error);
} finally {
setIsLoading(false);
}
},
[userDetails]
);
const fetchAllNotifications = useCallback(
async (filterParam: NotificationFilter = "all") => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails
? fetchApiNotifications(0, false, filterParam)
: Promise.resolve(),
]);
setIsLoading(false);
},
[fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
);
useEffect(() => {
fetchAllNotifications(filter);
}, [fetchAllNotifications, filter]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
(notification) => {
setLocalNotifications((prev) => [notification, ...prev]);
}
);
return () => unsubscribe();
}, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
// High priority notifications (priority === 1) - keep in API order
const highPriority: MergedNotification[] = apiNotifications
.filter((n) => n.priority === 1)
.map((n) => ({ ...n, source: "api" as const }));
// Low priority: other API notifications + local notifications, merged and sorted by date
const lowPriorityApi: MergedNotification[] = apiNotifications
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
// Filter local notifications based on current filter
const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
...n,
source: "local" as const,
}));
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate
);
return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => {
return mergedNotifications;
}, [mergedNotifications]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
}, []);
const handleMarkAsRead = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.patch(
`/profile/notifications/${id}/read`,
{
data: { id },
needsAuth: true,
}
);
setApiNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
} else {
await window.electron.markLocalNotificationRead(id);
setLocalNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
}
notifyCountChange();
} catch (error) {
logger.error("Failed to mark notification as read", error);
}
},
[notifyCountChange]
);
const handleMarkAllAsRead = useCallback(async () => {
try {
// Mark all API notifications as read
if (userDetails && apiNotifications.some((n) => !n.isRead)) {
await window.electron.hydraApi.patch(
`/profile/notifications/all/read`,
{ needsAuth: true }
);
setApiNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
}
// Mark all local notifications as read
await window.electron.markAllLocalNotificationsRead();
setLocalNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
notifyCountChange();
showSuccessToast(t("marked_all_as_read"));
} catch (error) {
logger.error("Failed to mark all as read", error);
showErrorToast(t("failed_to_mark_as_read"));
}
}, [
apiNotifications,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleDismiss = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.delete(
`/profile/notifications/${id}`,
{ needsAuth: true }
);
setApiNotifications((prev) => prev.filter((n) => n.id !== id));
setPagination((prev) => ({ ...prev, total: prev.total - 1 }));
} else {
await window.electron.deleteLocalNotification(id);
setLocalNotifications((prev) => prev.filter((n) => n.id !== id));
}
notifyCountChange();
} catch (error) {
logger.error("Failed to dismiss notification", error);
showErrorToast(t("failed_to_dismiss"));
}
},
[showErrorToast, t, notifyCountChange]
);
const removeNotificationFromState = useCallback(
(notification: MergedNotification) => {
if (notification.source === "api") {
setApiNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
} else {
setLocalNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}
},
[]
);
const removeNotificationWithDelay = useCallback(
(notification: MergedNotification, delayMs: number): Promise<void> => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
removeNotificationFromState(notification);
resolve();
}, delayMs);
clearingTimeoutsRef.current.push(timeout);
});
},
[removeNotificationFromState]
);
const handleClearAll = useCallback(async () => {
if (isClearing) return;
try {
setIsClearing(true);
// Clear any existing timeouts
clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Snapshot current notifications for staggered removal
const notificationsToRemove = [...displayedNotifications];
const totalNotifications = notificationsToRemove.length;
if (totalNotifications === 0) {
setIsClearing(false);
return;
}
// Remove items one by one with staggered delays for visual effect
const removalPromises = notificationsToRemove.map((notification, index) =>
removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS)
);
// Wait for all items to be removed from state
await Promise.all(removalPromises);
// Wait for the last exit animation to complete
await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS));
// Perform actual backend deletions (state is already cleared by staggered removal)
if (userDetails) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true,
});
}
await window.electron.clearAllLocalNotifications();
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
}
}, [
displayedNotifications,
isClearing,
removeNotificationWithDelay,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true, filter);
}
}, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted"));
}, [showSuccessToast, t]);
const handleRefuseFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_refused"));
}, [showSuccessToast, t]);
const renderNotification = (notification: MergedNotification) => {
const key =
notification.source === "local"
? `local-${notification.id}`
: `api-${notification.id}`;
return (
<motion.div
key={key}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
<LocalNotificationItem
notification={notification}
onDismiss={(id) => handleDismiss(id, "local")}
onMarkAsRead={(id) => handleMarkAsRead(id, "local")}
/>
) : (
<NotificationItem
notification={notification}
badges={badges}
onDismiss={(id) => handleDismiss(id, "api")}
onMarkAsRead={(id) => handleMarkAsRead(id, "api")}
onAcceptFriendRequest={handleAcceptFriendRequest}
onRefuseFriendRequest={handleRefuseFriendRequest}
/>
)}
</motion.div>
);
};
const unreadCount = useMemo(() => {
const apiUnread = apiNotifications.filter((n) => !n.isRead).length;
const localUnread = localNotifications.filter((n) => !n.isRead).length;
return apiUnread + localUnread;
}, [apiNotifications, localNotifications]);
const renderFilterTabs = () => (
<div className="notifications__filter-tabs">
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "all" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("all")}
>
{t("filter_all")}
</button>
{filter === "all" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "unread" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("unread")}
>
{t("filter_unread")}
{unreadCount > 0 && (
<span className="notifications__tab-badge">{unreadCount}</span>
)}
</button>
{filter === "unread" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
</div>
);
const hasNoNotifications = mergedNotifications.length === 0;
const shouldDisableActions = isClearing || hasNoNotifications;
const renderContent = () => {
if (isLoading && hasNoNotifications) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
</div>
);
}
return (
<div className="notifications">
<div className="notifications__header">
{renderFilterTabs()}
<div className="notifications__actions">
<Button
theme="outline"
onClick={handleMarkAllAsRead}
disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
</div>
{/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="wait">
<motion.div
key={filter}
className="notifications__content-wrapper"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{hasNoNotifications && !isClearing ? (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>
{filter === "unread"
? t("empty_filter_description")
: t("empty_description")}
</p>
</div>
) : (
<div className="notifications__list">
<AnimatePresence>
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
)}
</motion.div>
</AnimatePresence>
{pagination.hasMore && !isClearing && (
<div className="notifications__load-more">
<Button
theme="outline"
onClick={handleLoadMore}
disabled={isLoading}
>
{isLoading ? t("loading") : t("load_more")}
</Button>
</div>
)}
</div>
);
};
return <>{renderContent()}</>;
}

View File

@@ -0,0 +1,122 @@
@use "../../../scss/globals.scss";
.add-friend-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
min-width: 400px;
&__my-code {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
padding: calc(globals.$spacing-unit * 1.5);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
&__my-code-label {
font-size: 0.875rem;
color: globals.$muted-color;
font-weight: 500;
}
&__my-code-value {
font-size: 0.875rem;
color: globals.$body-color;
font-family: monospace;
font-weight: 600;
flex: 1;
}
&__copy-icon-button {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 4px;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
color: globals.$body-color;
}
}
&__actions {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: globals.$spacing-unit;
}
&__button {
align-self: flex-end;
white-space: nowrap;
}
&__pending-status {
color: globals.$body-color;
font-size: globals.$small-font-size;
text-align: center;
padding: calc(globals.$spacing-unit / 2);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-top: calc(globals.$spacing-unit * -1);
}
&__pending-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
h3 {
margin: 0;
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
}
&__pending-list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 300px;
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__friend-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: none;
cursor: pointer;
transition: all ease 0.2s;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__friend-name {
flex: 1;
font-weight: 600;
color: globals.$muted-color;
font-size: globals.$body-font-size;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,185 @@
import { Avatar, Button, Modal, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { CopyIcon } from "@primer/octicons-react";
import "./add-friend-modal.scss";
interface AddFriendModalProps {
readonly visible: boolean;
readonly onClose: () => void;
}
export function AddFriendModal({ visible, onClose }: AddFriendModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const {
sendFriendRequest,
updateFriendRequestState,
friendRequests,
fetchFriendRequests,
userDetails,
} = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const copyMyFriendCode = () => {
if (userDetails?.id) {
navigator.clipboard.writeText(userDetails.id);
showSuccessToast(t("friend_code_copied"));
}
};
useEffect(() => {
if (visible) {
setFriendCode("");
fetchFriendRequests();
}
}, [visible, fetchFriendRequests]);
const handleChangeFriendCode = (e: React.ChangeEvent<HTMLInputElement>) => {
const code = e.target.value.trim().slice(0, 8);
setFriendCode(code);
};
const validateFriendCode = (callback: () => void) => {
if (friendCode.length === 8) {
return callback();
}
showErrorToast(t("friend_code_length_error"));
};
const handleClickAddFriend = () => {
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
setFriendCode("");
showSuccessToast(t("request_sent"));
})
.catch(() => {
showErrorToast(t("error_adding_friend"));
})
.finally(() => {
setIsAddingFriend(false);
});
};
const handleClickSeeProfile = () => {
if (friendCode.length === 8) {
onClose();
navigate(`/profile/${friendCode}`);
}
};
const handleClickRequest = (userId: string) => {
onClose();
navigate(`/profile/${userId}`);
};
const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast(t("try_again"));
});
};
const sentRequests = friendRequests.filter((req) => req.type === "SENT");
const currentRequest =
friendCode.length === 8
? sentRequests.find((req) => req.id === friendCode)
: null;
return (
<Modal visible={visible} title={t("add_friends")} onClose={onClose}>
<div className="add-friend-modal">
{userDetails?.id && (
<div className="add-friend-modal__my-code">
<span className="add-friend-modal__my-code-label">
{t("your_friend_code")}
</span>
<span className="add-friend-modal__my-code-value">
{userDetails.id}
</span>
<button
onClick={copyMyFriendCode}
type="button"
className="add-friend-modal__copy-icon-button"
title={t("copy_friend_code")}
>
<CopyIcon size={16} />
</button>
</div>
)}
<div className="add-friend-modal__actions">
<TextField
label={t("friend_code")}
value={friendCode}
containerProps={{ style: { flex: 1 } }}
onChange={handleChangeFriendCode}
/>
<Button
disabled={isAddingFriend}
type="button"
className="add-friend-modal__button"
onClick={() => validateFriendCode(handleClickAddFriend)}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
theme="outline"
onClick={() => validateFriendCode(handleClickSeeProfile)}
disabled={isAddingFriend}
className="add-friend-modal__button"
type="button"
>
{t("see_profile")}
</Button>
</div>
{currentRequest && (
<div className="add-friend-modal__pending-status">{t("pending")}</div>
)}
{sentRequests.length > 0 && (
<div className="add-friend-modal__pending-container">
<h3>{t("pending")}</h3>
<div className="add-friend-modal__pending-list">
{sentRequests.map((request) => (
<button
key={request.id}
type="button"
className="add-friend-modal__friend-item"
onClick={() => handleClickRequest(request.id)}
>
<Avatar
src={request.profileImageUrl}
alt={request.displayName}
size={40}
/>
<span className="add-friend-modal__friend-name">
{request.displayName}
</span>
<Button
theme="outline"
onClick={(e) => {
e.stopPropagation();
handleCancelFriendRequest(request.id);
}}
type="button"
>
{t("cancel_request")}
</Button>
</button>
))}
</div>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,87 @@
@use "../../../scss/globals.scss";
.all-badges-modal {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 400px;
margin-top: calc(globals.$spacing-unit * -1);
&__title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__count {
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;
}
&__list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
transition: background-color ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__item-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__item-content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.5);
flex: 1;
min-width: 0;
}
&__item-title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$body-color;
margin: 0;
}
&__item-description {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
}

View File

@@ -0,0 +1,58 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { Modal } from "@renderer/components";
import { userProfileContext } from "@renderer/context";
import "./all-badges-modal.scss";
interface AllBadgesModalProps {
visible: boolean;
onClose: () => void;
}
export function AllBadgesModal({
visible,
onClose,
}: Readonly<AllBadgesModalProps>) {
const { t } = useTranslation("user_profile");
const { userProfile, badges } = useContext(userProfileContext);
const userBadges = userProfile?.badges
.map((badgeName) => badges.find((b) => b.name === badgeName))
.filter((badge) => badge !== undefined);
const modalTitle = (
<div className="all-badges-modal__title">
{t("badges")}
{userBadges && userBadges.length > 0 && (
<span className="all-badges-modal__count">{userBadges.length}</span>
)}
</div>
);
return (
<Modal visible={visible} title={modalTitle} onClose={onClose}>
<div className="all-badges-modal">
<div className="all-badges-modal__list">
{userBadges?.map((badge) => (
<div key={badge.name} className="all-badges-modal__item">
<div className="all-badges-modal__item-icon">
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
<div className="all-badges-modal__item-content">
<h3 className="all-badges-modal__item-title">{badge.title}</h3>
<p className="all-badges-modal__item-description">
{badge.description}
</p>
</div>
</div>
))}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,101 @@
@use "../../../scss/globals.scss";
.all-friends-modal {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 400px;
margin-top: calc(globals.$spacing-unit * -1);
&__title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__count {
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;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
border-radius: 8px;
cursor: pointer;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
&__info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
flex: 1;
min-width: 0;
}
&__name {
font-weight: 600;
color: globals.$muted-color;
font-size: globals.$body-font-size;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__game {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
font-size: globals.$small-font-size;
color: globals.$body-color;
img {
border-radius: 4px;
}
small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4);
color: globals.$body-color;
}
&__loading {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
&__load-more {
display: flex;
justify-content: center;
padding-top: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Modal, Avatar, Button } from "@renderer/components";
import { logger } from "@renderer/logger";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import type { UserFriend } from "@types";
import "./all-friends-modal.scss";
interface AllFriendsModalProps {
visible: boolean;
onClose: () => void;
userId: string;
isMe: boolean;
}
const PAGE_SIZE = 20;
export function AllFriendsModal({
visible,
onClose,
userId,
isMe,
}: AllFriendsModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const [friends, setFriends] = useState<UserFriend[]>([]);
const [totalFriends, setTotalFriends] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const fetchFriends = useCallback(
async (pageNum: number, append = false) => {
if (isLoading) return;
setIsLoading(true);
try {
const url = isMe ? "/profile/friends" : `/users/${userId}/friends`;
const response = await window.electron.hydraApi.get<{
totalFriends: number;
friends: UserFriend[];
}>(url, {
params: { take: PAGE_SIZE, skip: pageNum * PAGE_SIZE },
});
if (append) {
setFriends((prev) => [...prev, ...response.friends]);
} else {
setFriends(response.friends);
}
setTotalFriends(response.totalFriends);
setHasMore((pageNum + 1) * PAGE_SIZE < response.totalFriends);
setPage(pageNum + 1);
} catch (error) {
logger.error("Failed to fetch friends", error);
} finally {
setIsLoading(false);
}
},
[userId, isMe, isLoading]
);
useEffect(() => {
if (visible) {
setFriends([]);
setPage(0);
setHasMore(true);
fetchFriends(0, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, userId]);
const handleScroll = useCallback(() => {
if (!listRef.current || isLoading || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
if (scrollTop + clientHeight >= scrollHeight - 50) {
fetchFriends(page, true);
}
}, [isLoading, hasMore, page, fetchFriends]);
const handleFriendClick = (friendId: string) => {
onClose();
navigate(`/profile/${friendId}`);
};
const handleLoadMore = () => {
if (!isLoading && hasMore) {
fetchFriends(page, true);
}
};
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return <img alt={game.title} width={16} height={16} src={game.iconUrl} />;
}
return <SteamLogo width={16} height={16} />;
};
const modalTitle = (
<div className="all-friends-modal__title">
{t("friends")}
{totalFriends > 0 && (
<span className="all-friends-modal__count">{totalFriends}</span>
)}
</div>
);
return (
<Modal visible={visible} title={modalTitle} onClose={onClose}>
<div className="all-friends-modal">
{friends.length === 0 && !isLoading ? (
<div className="all-friends-modal__empty">
{t("no_friends_added")}
</div>
) : (
<div
ref={listRef}
className="all-friends-modal__list"
onScroll={handleScroll}
>
{friends.map((friend) => (
<div
key={friend.id}
className="all-friends-modal__item"
onClick={() => handleFriendClick(friend.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleFriendClick(friend.id);
}
}}
role="button"
tabIndex={0}
>
<Avatar
size={40}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div className="all-friends-modal__info">
<span className="all-friends-modal__name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="all-friends-modal__game">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</div>
))}
</div>
)}
{isLoading && (
<div className="all-friends-modal__loading">{t("loading")}...</div>
)}
{hasMore && !isLoading && friends.length > 0 && (
<div className="all-friends-modal__load-more">
<Button theme="outline" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,95 @@
@use "../../../scss/globals.scss";
.badges-box {
&__box {
padding: calc(globals.$spacing-unit * 2);
}
&__header {
display: flex;
justify-content: flex-end;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
width: 100%;
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
transition: background-color ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__item-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
img {
width: 28px;
height: 28px;
object-fit: contain;
}
}
&__item-content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.5);
flex: 1;
min-width: 0;
}
&__item-title {
font-size: 0.8rem;
font-weight: 600;
color: globals.$body-color;
margin: 0;
}
&__item-description {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
&__view-all-container {
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: flex-start;
}
&__view-all {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
&:hover {
color: globals.$muted-color;
}
}
}

View File

@@ -0,0 +1,67 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { AllBadgesModal } from "./all-badges-modal";
import "./badges-box.scss";
const MAX_VISIBLE_BADGES = 4;
export function BadgesBox() {
const { userProfile, badges } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const [showAllBadgesModal, setShowAllBadgesModal] = useState(false);
if (!userProfile?.badges.length) return null;
const visibleBadges = userProfile.badges.slice(0, MAX_VISIBLE_BADGES);
const hasMoreBadges = userProfile.badges.length > MAX_VISIBLE_BADGES;
return (
<>
<div className="badges-box__box">
<div className="badges-box__list">
{visibleBadges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
if (!badge) return null;
return (
<div key={badge.name} className="badges-box__item">
<div className="badges-box__item-icon">
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
<div className="badges-box__item-content">
<h3 className="badges-box__item-title">{badge.title}</h3>
<p className="badges-box__item-description">
{badge.description}
</p>
</div>
</div>
);
})}
</div>
{hasMoreBadges && (
<div className="badges-box__view-all-container">
<button
type="button"
className="badges-box__view-all"
onClick={() => setShowAllBadgesModal(true)}
>
{t("view_all")}
</button>
</div>
)}
</div>
<AllBadgesModal
visible={showAllBadgesModal}
onClose={() => setShowAllBadgesModal(false)}
/>
</>
);
}

View File

@@ -1,18 +1,48 @@
@use "../../../scss/globals.scss";
.friends-box {
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
&__box {
padding: calc(globals.$spacing-unit * 2);
position: relative;
&--empty {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
}
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
&__empty-text {
color: globals.$muted-color;
font-size: globals.$small-font-size;
margin: 0;
text-align: center;
}
&__add-friend-button {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
&:hover {
color: globals.$muted-color;
}
}
&__view-all-container {
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: flex-start;
}
&__list {
@@ -44,11 +74,12 @@
&__friend-name {
color: globals.$muted-color;
font-weight: bold;
font-size: globals.$body-font-size;
font-size: 0.8rem;
font-weight: 600;
}
&__game-info {
font-size: 0.75rem;
display: flex;
gap: globals.$spacing-unit;
align-items: center;
@@ -63,4 +94,19 @@
&__game-image {
border-radius: 4px;
}
&__view-all {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
&:hover {
color: globals.$muted-color;
}
}
}

View File

@@ -1,15 +1,25 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useUserDetails } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar, Link } from "@renderer/components";
import { AllFriendsModal } from "./all-friends-modal";
import { AddFriendModal } from "./add-friend-modal";
import "./friends-box.scss";
const MAX_VISIBLE_FRIENDS = 5;
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const [showAllFriendsModal, setShowAllFriendsModal] = useState(false);
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
const hasFriends = userProfile?.friends && userProfile.friends.length > 0;
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
@@ -26,24 +36,25 @@ export function FriendsBox() {
return <SteamLogo width={16} height={16} />;
};
if (!userProfile?.friends.length) return null;
if (!hasFriends) {
if (!isMe) return null;
return (
<div className="friends-box__box friends-box__box--empty">
<p className="friends-box__empty-text">{t("no_friends_yet")}</p>
</div>
);
}
const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS);
const totalFriends = userProfile.friends.length;
const showViewAllButton = totalFriends > MAX_VISIBLE_FRIENDS;
return (
<div>
<div className="friends-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("friends")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.friendsCount)}
</span>
)}
</div>
</div>
<>
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
{visibleFriends.map((friend) => (
<li
key={friend.id}
title={
@@ -77,7 +88,61 @@ export function FriendsBox() {
</li>
))}
</ul>
{showViewAllButton && (
<div className="friends-box__view-all-container">
<button
type="button"
className="friends-box__view-all"
onClick={() => setShowAllFriendsModal(true)}
>
{t("view_all")}
</button>
</div>
)}
</div>
</div>
{userProfile && (
<>
<AllFriendsModal
visible={showAllFriendsModal}
onClose={() => setShowAllFriendsModal(false)}
userId={userProfile.id}
isMe={isMe}
/>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
</>
)}
</>
);
}
export function FriendsBoxAddButton() {
const { userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
if (!isMe) return null;
return (
<>
<button
type="button"
className="friends-box__add-friend-button"
onClick={() => setShowAddFriendModal(true)}
>
<PlusIcon size={16} />
{t("add_friends")}
</button>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
</>
);
}

View File

@@ -14,10 +14,11 @@ import { useTranslation } from "react-i18next";
import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { BadgesBox } from "./badges-box";
import { FriendsBox, FriendsBoxAddButton } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box";
import { ProfileSection } from "../profile-section/profile-section";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
@@ -186,8 +187,6 @@ export function ProfileContent() {
);
setReviews(response.reviews);
setReviewsTotalCount(response.totalCount);
} catch (error) {
// Error handling for fetching reviews
} finally {
setIsLoadingReviews(false);
}
@@ -377,7 +376,7 @@ export function ProfileContent() {
const hasAnyGames = hasGames || hasPinnedGames;
const shouldShowRightContent =
hasAnyGames || userProfile.friends.length > 0;
hasAnyGames || userProfile.friends.length > 0 || isMe;
return (
<section className="profile-content__section">
@@ -426,10 +425,35 @@ export function ProfileContent() {
{shouldShowRightContent && (
<div className="profile-content__right-content">
<UserStatsBox />
<UserKarmaBox />
<RecentGamesBox />
<FriendsBox />
{userStats && (
<ProfileSection title={t("stats")} defaultOpen={true}>
<UserStatsBox />
</ProfileSection>
)}
{userProfile?.badges.length > 0 && (
<ProfileSection
title={t("badges")}
count={userProfile.badges.length}
defaultOpen={true}
>
<BadgesBox />
</ProfileSection>
)}
{userProfile?.recentGames.length > 0 && (
<ProfileSection title={t("activity")} defaultOpen={true}>
<RecentGamesBox />
</ProfileSection>
)}
{(userProfile?.friends.length > 0 || isMe) && (
<ProfileSection
title={t("friends")}
count={userStats?.friendsCount || userProfile.friends.length}
action={<FriendsBoxAddButton />}
defaultOpen={true}
>
<FriendsBox />
</ProfileSection>
)}
<ReportProfile />
</div>
)}

View File

@@ -2,19 +2,9 @@
.recent-games {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;
@@ -57,13 +47,15 @@
}
&__game-title {
font-weight: bold;
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__game-description {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: globals.$spacing-unit;

View File

@@ -42,38 +42,32 @@ export function RecentGamesBox() {
if (!userProfile?.recentGames.length) return null;
return (
<div>
<div className="recent-games__section-header">
<h2>{t("activity")}</h2>
</div>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className="recent-games__game-image"
/>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className="recent-games__game-image"
/>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,47 +0,0 @@
@use "../../../scss/globals.scss";
.user-karma {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__stats-row {
display: flex;
align-items: center;
color: globals.$body-color;
}
&__description {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
font-weight: 600;
font-size: 1.1rem;
}
&__info {
padding-top: calc(globals.$spacing-unit * 0.5);
}
&__info-text {
color: globals.$muted-color;
font-size: 0.85rem;
line-height: 1.4;
}
}

View File

@@ -1,43 +0,0 @@
import { useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { Award } from "lucide-react";
import "./user-karma-box.scss";
export function UserKarmaBox() {
const { isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
// Get karma from userDetails (for current user) or userProfile (for other users)
const karma = isMe ? userDetails?.karma : userProfile?.karma;
// Don't show if karma is not available
if (karma === undefined || karma === null) return null;
return (
<div>
<div className="user-karma__section-header">
<h2>{t("karma")}</h2>
</div>
<div className="user-karma__box">
<div className="user-karma__content">
<div className="user-karma__stats-row">
<p className="user-karma__description">
<Award size={20} /> {numberFormatter.format(karma)}{" "}
{t("karma_count")}
</p>
</div>
<div className="user-karma__info">
<small className="user-karma__info-text">
{t("karma_description")}
</small>
</div>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More