Compare commits

...

170 Commits
v3.7.6 ... main

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
Chubby Granny Chaser
35736dd2d9 Merge branch 'main' into feat/wrapped-in-profile 2025-12-26 21:39:24 +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
Chubby Granny Chaser
86d5547aa1 Merge pull request #1903 from Wkeynhk/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Adding new hosters
2025-12-25 23:13:24 +00:00
Wkeynhk
358f41b4ba . 2025-12-26 01:34:09 +03:00
Wkeynhk
7f0dc5eee4 . 2025-12-26 01:25:56 +03:00
Wkeynhk
067f7a00be . 2025-12-26 01:19:04 +03:00
Wkeynhk
37f085e2c0 . 2025-12-26 01:13:36 +03:00
Wkeynhk
f8ac284bc2 New hosters 2025-12-25 14:16:11 +03: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
Chubby Granny Chaser
b1d72828bb Merge branch 'main' into feat/wrapped-in-profile 2025-12-19 02:03:03 +00:00
Chubby Granny Chaser
4c09f915c6 Merge pull request #1901 from hydralauncher/fix/fullscreen-modal
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
style: enhance overlay styling and adjust close button positioning
2025-12-19 02:02:46 +00:00
Moyasee
24d65b50b4 style: enhance overlay styling and adjust close button positioning 2025-12-18 22:56:50 +02:00
Moyase
f77b2116c1 Merge branch 'main' into feat/wrapped-in-profile 2025-12-18 22:44:25 +02:00
Moyasee
6cd65d6239 feat: implement add friend modal and enhance friends box with add friend functionality 2025-12-18 22:41:28 +02:00
Chubby Granny Chaser
34681b3bc2 Merge pull request #1897 from Stormm232/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
v3.7.6 - Hungarian Translation
2025-12-18 19:33:22 +00:00
Chubby Granny Chaser
4b71a3f5bb Merge branch 'main' into main 2025-12-18 19:33:15 +00:00
Chubby Granny Chaser
a0f669c97b Merge pull request #1896 from hydralauncher/fix/peak-download-speed
Fix: peak download speed using current network speed fix
2025-12-18 19:33:00 +00:00
Chubby Granny Chaser
96fca88601 Merge branch 'main' into fix/peak-download-speed 2025-12-18 19:32:11 +00:00
Chubby Granny Chaser
3f65bb86a8 Merge pull request #1899 from hydralauncher/feat/LBX-301
Feat: Ability to open profile image in fullscreen
2025-12-18 19:30:51 +00:00
Chubby Granny Chaser
b494c7c8ec Merge branch 'main' into main 2025-12-18 19:30:27 +00:00
Chubby Granny Chaser
e10007b1c3 Merge branch 'main' into feat/LBX-301 2025-12-18 19:29:52 +00:00
Chubby Granny Chaser
dae825a75a Merge pull request #1900 from moi952/feat/translation-fr-v3.7.6
feat: add missing French translations
2025-12-18 19:29:33 +00:00
moi952
677f34fe3d feat: add missing French translations 2025-12-16 22:41:56 +01:00
Moyasee
1524e73ee6 refactor(fullscreen-media-modal): replace div with dialog element for improved semantics 2025-12-16 15:49:10 +02:00
Moyasee
40d428c19e refactor(fullscreen-media-modal): remove unused useCallback import 2025-12-16 15:43:43 +02:00
Moyasee
affa7a2b2e feat: add fullscreen media modal to profile hero for avatar display 2025-12-16 15:42:34 +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
Kiwo.2
d5e6bed3b7 Re-written some parts, Adjusted to new version 2025-12-14 15:58:27 +01:00
Kiwo.2
214267df7e Re-written some parts, Adjusted to new version 2025-12-14 15:09:40 +01:00
Kiwo.2
21f46c9af3 Merge branch 'main' of https://github.com/Stormm232/hydra 2025-12-14 15:05:52 +01:00
Kiwo.2
cc5e0014f7 Re-written some parts, Adjusted to new version 2025-12-14 15:04:51 +01:00
Moyasee
ccb754fa13 feat(profile): add hasCompletedWrapped2025 flag to UserProfile and update ProfileHero rendering logic 2025-12-14 15:14:54 +02:00
Moyasee
142bd3156c fix: ensure downloader value is properly checked and converted to number in DownloadGroup component 2025-12-14 11:27:29 +02:00
Moyasee
95a7bc2236 feat: add new translation keys for network and peak in English and Portuguese locales 2025-12-13 18:32:16 +02:00
Moyasee
78d2be85f2 style: format peak speed calculation for improved readability in DownloadGroup component 2025-12-13 18:15:21 +02:00
Moyasee
67ea9e78a2 feat: enhance download tracking by adding speed history management in download slice 2025-12-13 18:14:52 +02:00
Moyasee
67f863e0f3 fix: remove unused peak speed update dispatch in DownloadGroup component 2025-12-13 17:41:15 +02:00
Moyasee
77b6f1b2ad feat: add peak speed tracking and management in download slice 2025-12-13 16:10:57 +02:00
Moyasee
5329cc446f Merge branch 'feat/wrapped-in-profile' of https://github.com/hydralauncher/hydra into feat/wrapped-in-profile 2025-12-13 09:17:02 +02:00
Moyasee
21a0ad1500 feat(profile): add translation for "View My Wrapped 2025" button 2025-12-13 09:16:54 +02:00
Moyase
9ffaee12d1 Merge branch 'main' into feat/wrapped-in-profile 2025-12-12 20:44:15 +02:00
Moyasee
8555274589 ci: formatting 2025-12-12 20:43:14 +02:00
Moyasee
a152c89d7f feat(profile): update Wrapped 2025 button and add new gradient style 2025-12-12 20:42:54 +02:00
Moyasee
879f0baad7 refactor(profile): remove overlay from WrappedFullscreenModal to streamline UI 2025-12-12 17:51:31 +02:00
Moyasee
c025dc199d style(profile): add overlay to WrappedFullscreenModal for improved UI interaction 2025-12-12 17:49:12 +02:00
Chubby Granny Chaser
1552a5f359 Merge pull request #1894 from hydralauncher/feat/LBX-188
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Feat: proper archive extraction and deletion after completion
2025-12-12 15:39:42 +00:00
Moyasee
5b4b258526 refactor: remove WrappedConfirmModal and integrate WrappedFullscreenModal in profile 2025-12-12 17:29:44 +02:00
Moyasee
0268829946 feat: add Wrapped 2025 view in profile 2025-12-12 13:53:12 +02:00
Moyasee
63f8289d0a feat: implement archive deletion prompt and translations for confirmation messages 2025-12-12 12:44:02 +02:00
Moyasee
0470958629 refactor(decky-plugin): simplify plugin extraction logic using async/await 2025-12-11 15:35:40 +02:00
Moyasee
3b574e6578 feat: add extraction progress tracking and UI updates 2025-12-11 15:25:44 +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
149 changed files with 7849 additions and 2078 deletions

View File

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

View File

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

View File

@@ -54,9 +54,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_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 }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_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 }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -71,9 +72,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_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 }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_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 }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} 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) [![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) [![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) ![Hydra Launcher Home Page](./docs/screenshot.png)

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.6", "version": "3.8.1",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -70,6 +70,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"node-7z": "^3.0.0",
"parse-torrent": "^11.0.18", "parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3", "rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
@@ -90,6 +91,7 @@
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winreg": "^1.2.5", "winreg": "^1.2.5",
"workwonders-sdk": "0.0.10",
"ws": "^8.18.1", "ws": "^8.18.1",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0" "yup": "^1.5.0"

2
proto

Submodule proto updated: 7a23620f93...6f11c99c57

View File

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

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Game has no executable selected", "game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in", "sign_in": "Sign in",
"friends": "Friends", "friends": "Friends",
"notifications": "Notifications",
"need_help": "Need help?", "need_help": "Need help?",
"favorites": "Favorites", "favorites": "Favorites",
"playable_button_title": "Show only games you can play now", "playable_button_title": "Show only games you can play now",
@@ -115,6 +116,7 @@
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…", "calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)", "checking_files": "Checking {{title}} files… ({{percentage}} complete)",
"extracting": "Extracting {{title}}… ({{percentage}} complete)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Installation complete", "installation_complete": "Installation complete",
"installation_complete_message": "Common redistributables installed successfully" "installation_complete_message": "Common redistributables installed successfully"
@@ -173,6 +175,7 @@
"repacks_modal_description": "Choose the repack you want to download", "repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>", "select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now", "download_now": "Download now",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.", "no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options", "download_options": "Download options",
"download_path": "Download path", "download_path": "Download path",
@@ -182,6 +185,12 @@
"open_screenshot": "Open screenshot {{number}}", "open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings", "download_settings": "Download settings",
"downloader": "Downloader", "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", "select_executable": "Select",
"no_executable_selected": "No executable selected", "no_executable_selected": "No executable selected",
"open_folder": "Open folder", "open_folder": "Open folder",
@@ -202,6 +211,7 @@
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra", "danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress", "download_in_progress": "Download in progress",
"download_paused": "Download paused", "download_paused": "Download paused",
"extracting": "Extracting",
"last_downloaded_option": "Last downloaded option", "last_downloaded_option": "Last downloaded option",
"new_download_option": "New", "new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut", "create_steam_shortcut": "Create Steam shortcut",
@@ -394,6 +404,10 @@
"completed": "Completed", "completed": "Completed",
"removed": "Not downloaded", "removed": "Not downloaded",
"cancel": "Cancel", "cancel": "Cancel",
"cancel_download": "Cancel download?",
"cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.",
"keep_downloading": "No, keep downloading",
"yes_cancel": "Yes, cancel",
"filter": "Filter downloaded games", "filter": "Filter downloaded games",
"remove": "Remove", "remove": "Remove",
"downloading_metadata": "Downloading metadata…", "downloading_metadata": "Downloading metadata…",
@@ -414,7 +428,13 @@
"resume_seeding": "Resume seeding", "resume_seeding": "Resume seeding",
"options": "Manage", "options": "Manage",
"extract": "Extract files", "extract": "Extract files",
"extracting": "Extracting files…" "extracting": "Extracting files…",
"delete_archive_title": "Would you like to delete {{fileName}}?",
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
"yes": "Yes",
"no": "No",
"network": "NETWORK",
"peak": "PEAK"
}, },
"settings": { "settings": {
"downloads_path": "Downloads path", "downloads_path": "Downloads path",
@@ -550,6 +570,7 @@
"show_download_speed_in_megabytes": "Show download speed in megabytes per second", "show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download", "extract_files_by_default": "Extract files by default after download",
"enable_steam_achievements": "Enable search for Steam achievements", "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", "achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left", "top-left": "Top left",
"top-center": "Top center", "top-center": "Top center",
@@ -577,7 +598,10 @@
"notification_preview": "Achievement Notification Preview", "notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game", "enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
"downloads": "Downloads",
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@@ -654,6 +678,7 @@
"sending": "Sending", "sending": "Sending",
"friend_request_sent": "Friend request sent", "friend_request_sent": "Friend request sent",
"friends": "Friends", "friends": "Friends",
"badges": "Badges",
"friends_list": "Friends list", "friends_list": "Friends list",
"user_not_found": "User not found", "user_not_found": "User not found",
"block_user": "Block user", "block_user": "Block user",
@@ -664,12 +689,17 @@
"ignore_request": "Ignore request", "ignore_request": "Ignore request",
"cancel_request": "Cancel request", "cancel_request": "Cancel request",
"undo_friendship": "Undo friendship", "undo_friendship": "Undo friendship",
"friendship_removed": "Friend removed",
"request_accepted": "Request accepted", "request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully", "user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}", "user_block_modal_text": "This will block {{displayName}}",
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"unblock": "Unblock", "unblock": "Unblock",
"no_friends_added": "You have no added friends", "no_friends_added": "You have no added friends",
"no_friends_yet": "You haven't added any friends yet",
"view_all": "View all",
"load_more": "Load more",
"loading": "Loading",
"pending": "Pending", "pending": "Pending",
"no_pending_invites": "You have no pending invites", "no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users", "no_blocked_users": "You have no blocked users",
@@ -693,8 +723,16 @@
"report_reason_other": "Other", "report_reason_other": "Other",
"profile_reported": "Profile reported", "profile_reported": "Profile reported",
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
"replace_banner": "Replace banner",
"remove_banner": "Remove banner",
"remove_banner_modal_title": "Remove banner?",
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
"remove": "Remove",
"background_image_updated": "Background image updated", "background_image_updated": "Background image updated",
"stats": "Stats", "stats": "Stats",
"achievements": "achievements", "achievements": "achievements",
@@ -712,10 +750,10 @@
"game_added_to_pinned": "Game added to pinned", "game_added_to_pinned": "Game added to pinned",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews", "user_reviews": "Reviews",
"delete_review": "Delete Review", "delete_review": "Delete Review",
"loading_reviews": "Loading reviews..." "loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025"
}, },
"library": { "library": {
"library": "Library", "library": "Library",
@@ -766,5 +804,41 @@
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!", "hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More", "learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus" "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", "game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar Sesión", "sign_in": "Iniciar Sesión",
"friends": "Amigos", "friends": "Amigos",
"notifications": "Notificaciones",
"need_help": "¿Necesitás ayuda?", "need_help": "¿Necesitás ayuda?",
"favorites": "Favoritos", "favorites": "Favoritos",
"playable_button_title": "Solo mostrar juegos que podés jugar en este momento", "playable_button_title": "Solo mostrar juegos que podés jugar en este momento",
@@ -115,6 +116,7 @@
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}", "downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…", "calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)", "checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
"extracting": "Extrayendo {{title}}… ({{percentage}} completado)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Instalación completada", "installation_complete": "Instalación completada",
"installation_complete_message": "Common redistributables instalados correctamente" "installation_complete_message": "Common redistributables instalados correctamente"
@@ -173,6 +175,7 @@
"repacks_modal_description": "Elegí el repack que querés descargar", "repacks_modal_description": "Elegí el repack que querés descargar",
"select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>", "select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
"download_now": "Descargar ahora", "download_now": "Descargar ahora",
"loading": "Cargando...",
"no_shop_details": "No se pudieron obtener detalles de la tienda.", "no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga", "download_options": "Opciones de descarga",
"download_path": "Ruta de descarga", "download_path": "Ruta de descarga",
@@ -182,6 +185,12 @@
"open_screenshot": "Abrir captura número {{number}}", "open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes", "download_settings": "Descargar ajustes",
"downloader": "Descargador", "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", "select_executable": "Seleccionar",
"no_executable_selected": "Sin ejecutable seleccionado", "no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta", "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", "danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"extracting": "Extrayendo",
"last_downloaded_option": "Última opción de descarga", "last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo", "new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam", "create_steam_shortcut": "Crear atajo de Steam",
@@ -394,6 +404,10 @@
"completed": "Completado", "completed": "Completado",
"removed": "No descargado", "removed": "No descargado",
"cancel": "Cancelar", "cancel": "Cancelar",
"cancel_download": "¿Cancelar descarga?",
"cancel_download_description": "¿Estás seguro de que querés cancelar esta descarga? Todos los archivos descargados serán eliminados.",
"keep_downloading": "No, seguir descargando",
"yes_cancel": "Sí, cancelar",
"filter": "Filtrar juegos descargados", "filter": "Filtrar juegos descargados",
"remove": "Remover", "remove": "Remover",
"downloading_metadata": "Descargando metadatos…", "downloading_metadata": "Descargando metadatos…",
@@ -414,7 +428,13 @@
"resume_seeding": "Continuar sembrando", "resume_seeding": "Continuar sembrando",
"options": "Administrar", "options": "Administrar",
"extract": "Extraer archivos", "extract": "Extraer archivos",
"extracting": "Extrayendo archivos…" "extracting": "Extrayendo archivos…",
"delete_archive_title": "¿Querés eliminar {{fileName}}?",
"delete_archive_description": "El archivo se extrajo exitosamente y ya no es necesario.",
"yes": "Sí",
"no": "No",
"network": "RED",
"peak": "PICO"
}, },
"settings": { "settings": {
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
@@ -538,6 +558,7 @@
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo", "show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
"extract_files_by_default": "Extraer archivos por defecto después de descargar", "extract_files_by_default": "Extraer archivos por defecto después de descargar",
"enable_steam_achievements": "Habilitar búsqueda de logros de Steam", "enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
"enable_new_download_options_badges": "Mostrar badges de nuevas opciones de descarga",
"achievement_custom_notification_position": "Posición de notificación de logros", "achievement_custom_notification_position": "Posición de notificación de logros",
"top-left": "Superior Izquierda", "top-left": "Superior Izquierda",
"top-center": "Superior Centro", "top-center": "Superior Centro",
@@ -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.", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"change_achievement_sound": "Cambiar sonido de logro", "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.", "downloads": "Descargas",
"download_source_failed": "Error", "use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
"download_source_matched": "Actualizado", "cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso"
"download_source_matching": "Actualizando",
"download_source_no_information": "Sin información disponible",
"download_source_pending_matching": "Actualizando pronto",
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
"hydra_cloud": "Hydra Cloud",
"preview_sound": "Vista previa de sonido",
"remove_achievement_sound": "Eliminar sonido de logros",
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
}, },
"notifications": { "notifications": {
"download_complete": "Descarga completada", "download_complete": "Descarga completada",
@@ -651,6 +662,7 @@
"sending": "Enviando", "sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada", "friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amistades", "friends": "Amistades",
"badges": "Insignias",
"friends_list": "Lista de amistades", "friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado", "user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario", "block_user": "Bloquear usuario",
@@ -661,12 +673,17 @@
"ignore_request": "Ignorar solicitud", "ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud", "cancel_request": "Cancelar solicitud",
"undo_friendship": "Deshacer amistad", "undo_friendship": "Deshacer amistad",
"friendship_removed": "Amigo eliminado",
"request_accepted": "Solicitud aceptada", "request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente", "user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}", "user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados", "blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "No tenés amistades añadidas", "no_friends_added": "No tenés amistades añadidas",
"no_friends_yet": "Aún no has agregado ningún amigo",
"view_all": "Ver todo",
"load_more": "Cargar más",
"loading": "Cargando",
"pending": "Pendiente", "pending": "Pendiente",
"no_pending_invites": "No tenés invitaciones pendientes", "no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie", "no_blocked_users": "No has bloqueado a nadie",
@@ -690,8 +707,16 @@
"report_reason_other": "Otros", "report_reason_other": "Otros",
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:", "your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"upload_banner": "Subir banner", "upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…", "uploading_banner": "Subiendo banner…",
"change_banner": "Cambiar banner",
"replace_banner": "Reemplazar banner",
"remove_banner": "Eliminar banner",
"remove_banner_modal_title": "¿Eliminar banner?",
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
"remove": "Eliminar",
"background_image_updated": "Imagen de fondo actualizada", "background_image_updated": "Imagen de fondo actualizada",
"stats": "Estadísticas", "stats": "Estadísticas",
"achievements": "logros", "achievements": "logros",
@@ -710,11 +735,11 @@
"amount_minutes_short": "{{amount}}m", "amount_minutes_short": "{{amount}}m",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:", "sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados", "game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas", "user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...", "loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Sin reseñas aún", "no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña" "delete_review": "Eliminar reseña"
}, },
@@ -767,5 +792,41 @@
"all_games": "Todos los Juegos", "all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente", "recently_played": "Jugados Recientemente",
"favorites": "Favoritos" "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_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin", "game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma", "karma": "Karma",
"karma_count": "karmaa", "karma_count": "karmaa"
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Saavutus avattu", "achievement_unlocked": "Saavutus avattu",

View File

@@ -27,7 +27,69 @@
"friends": "Amis", "friends": "Amis",
"need_help": "Besoin d'aide ?", "need_help": "Besoin d'aide ?",
"favorites": "Favoris", "favorites": "Favoris",
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant" "playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
"library": "Bibliothèque",
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
"custom_game_modal": "Ajouter un jeu personnalisé",
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
"custom_game_modal_executable_path": "Chemin de l'exécutable",
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
"custom_game_modal_title": "Titre",
"custom_game_modal_enter_title": "Entrer le titre",
"custom_game_modal_browse": "Parcourir",
"custom_game_modal_cancel": "Annuler",
"custom_game_modal_add": "Ajouter le jeu",
"custom_game_modal_adding": "Ajout du jeu…",
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
"custom_game_modal_failed": "Échec de lajout du jeu personnalisé",
"custom_game_modal_executable": "Exécutable",
"edit_game_modal": "Personnaliser les ressources",
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
"edit_game_modal_title": "Titre",
"edit_game_modal_enter_title": "Entrer le titre",
"edit_game_modal_image": "Image",
"edit_game_modal_select_image": "Sélectionner une image",
"edit_game_modal_browse": "Parcourir",
"edit_game_modal_image_preview": "Aperçu de limage",
"edit_game_modal_icon": "Icône",
"edit_game_modal_select_icon": "Sélectionner une icône",
"edit_game_modal_icon_preview": "Aperçu de licône",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Sélectionner un logo",
"edit_game_modal_logo_preview": "Aperçu du logo",
"edit_game_modal_hero": "Bannière de la bibliothèque",
"edit_game_modal_select_hero": "Sélectionner limage de bannière",
"edit_game_modal_hero_preview": "Aperçu de la bannière",
"edit_game_modal_cancel": "Annuler",
"edit_game_modal_update": "Mettre à jour",
"edit_game_modal_updating": "Mise à jour…",
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
"edit_game_modal_success": "Ressources mises à jour avec succès",
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
"edit_game_modal_image_filter": "Image",
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
"edit_game_modal_assets": "Ressources",
"edit_game_modal_drop_icon_image_here": "Déposez limage de licône ici",
"edit_game_modal_drop_logo_image_here": "Déposez limage du logo ici",
"edit_game_modal_drop_hero_image_here": "Déposez limage de la bannière ici",
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer licône",
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
"install_decky_plugin": "Installer le plugin Decky",
"update_decky_plugin": "Mettre à jour le plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
"decky_plugin_installation_failed": "Échec de linstallation du plugin Decky : {{error}}",
"decky_plugin_installation_error": "Erreur lors de linstallation du plugin Decky : {{error}}",
"confirm": "Confirmer",
"cancel": "Annuler"
}, },
"header": { "header": {
"search": "Rechercher", "search": "Rechercher",
@@ -37,7 +99,15 @@
"search_results": "Résultats de la recherche", "search_results": "Résultats de la recherche",
"settings": "Paramètres", "settings": "Paramètres",
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.", "version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger." "version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.",
"search_library": "Rechercher dans la bibliothèque",
"recent_searches": "Recherches récentes",
"suggestions": "Suggestions",
"clear_history": "Effacer",
"remove_from_history": "Supprimer de l'historique",
"loading": "Chargement…",
"no_results": "Aucun résultat",
"library": "Bibliothèque"
}, },
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours", "no_downloads_in_progress": "Aucun téléchargement en cours",
@@ -47,7 +117,8 @@
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)", "checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Installation terminée", "installation_complete": "Installation terminée",
"installation_complete_message": "Redistribuables communs installés avec succès" "installation_complete_message": "Redistribuables communs installés avec succès",
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
}, },
"catalogue": { "catalogue": {
"search": "Filtrer…", "search": "Filtrer…",
@@ -198,7 +269,113 @@
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.", "download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
"game_removed_from_favorites": "Jeu retiré des favoris", "game_removed_from_favorites": "Jeu retiré des favoris",
"game_added_to_favorites": "Jeu ajouté aux favoris", "game_added_to_favorites": "Jeu ajouté aux favoris",
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés" "automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés",
"already_in_library": "Déjà dans la bibliothèque",
"create_shortcut_simple": "Créer un raccourci",
"properties": "Propriétés",
"extracting": "Extraction en cours",
"new_download_option": "Nouveau",
"create_steam_shortcut": "Créer un raccourci Steam",
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"failed_update_favorites": "Échec de la mise à jour des favoris",
"game_removed_from_library": "Jeu retiré de la bibliothèque",
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
"files_removed_success": "Fichiers supprimés avec succès",
"failed_remove_files": "Échec de la suppression des fichiers",
"rating_count": "Évaluations",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"reviews": "Avis",
"review_played_for": "Temps de jeu",
"leave_a_review": "Laisser un avis",
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
"sort_newest": "Les plus récents",
"sort_oldest": "Les plus anciens",
"sort_highest_score": "Meilleure note",
"sort_lowest_score": "Note la plus basse",
"sort_most_voted": "Les plus votés",
"no_reviews_yet": "Aucun avis pour le moment",
"be_first_to_review": "Soyez le premier à donner votre avis !",
"rating": "Note",
"rating_stats": "Évaluation",
"rating_very_negative": "Très négatif",
"rating_negative": "Négatif",
"rating_neutral": "Neutre",
"rating_positive": "Positif",
"rating_very_positive": "Très positif",
"submit_review": "Envoyer",
"submitting": "Envoi…",
"review_submitted_successfully": "Avis envoyé avec succès !",
"review_submission_failed": "Échec de lenvoi de lavis. Veuillez réessayer.",
"review_cannot_be_empty": "Le champ de lavis ne peut pas être vide.",
"review_deleted_successfully": "Avis supprimé avec succès.",
"review_deletion_failed": "Échec de la suppression de lavis.",
"loading_reviews": "Chargement des avis…",
"loading_more_reviews": "Chargement de plus davis…",
"load_more_reviews": "Charger plus davis",
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
"yes": "Oui",
"maybe_later": "Peut-être plus tard",
"backup_failed": "Échec de la sauvegarde",
"update_playtime_title": "Mettre à jour le temps de jeu",
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
"update_playtime": "Mettre à jour le temps de jeu",
"update_playtime_success": "Temps de jeu mis à jour avec succès",
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
"update_game_playtime": "Mettre à jour le temps de jeu",
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
"game_removed_from_pinned": "Jeu retiré des épinglés",
"game_added_to_pinned": "Jeu ajouté aux épinglés",
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
"artifact_renamed": "Sauvegarde renommée avec succès",
"rename_artifact": "Renommer la sauvegarde",
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
"artifact_name_label": "Nom de la sauvegarde",
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
"save_changes": "Enregistrer les modifications",
"required_field": "Ce champ est requis",
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
"freeze_backup": "Épingler pour éviter lécrasement automatique",
"unfreeze_backup": "Désépingler",
"backup_frozen": "Sauvegarde épinglée",
"backup_unfrozen": "Sauvegarde désépinglée",
"backup_freeze_failed": "Échec de lépinglage de la sauvegarde",
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
"edit_game_modal_button": "Personnaliser les ressources du jeu",
"game_details": "Détails du jeu",
"prices": "Prix",
"no_prices_found": "Aucun prix trouvé",
"view_all_prices": "Cliquer pour voir tous les prix",
"retail_price": "Prix officiel",
"keyshop_price": "Prix Keyshop",
"historical_retail": "Historique officiel",
"historical_keyshop": "Historique Keyshop",
"language": "Langue",
"caption": "Sous-titres",
"audio": "Audio",
"filter_by_source": "Filtrer par source",
"no_repacks_found": "Aucune source trouvée pour ce jeu",
"delete_review": "Supprimer lavis",
"remove_review": "Retirer lavis",
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
"delete_review_modal_description": "Cette action est irréversible.",
"delete_review_modal_delete_button": "Supprimer",
"delete_review_modal_cancel_button": "Annuler",
"vote_failed": "Échec de lenregistrement de votre vote. Veuillez réessayer.",
"show_original": "Afficher loriginal",
"show_translation": "Afficher la traduction",
"show_original_translated_from": "Afficher loriginal (traduit depuis {{language}})",
"hide_original": "Masquer loriginal",
"review_from_blocked_user": "Avis dun utilisateur bloqué",
"show": "Afficher",
"hide": "Masquer"
}, },
"activation": { "activation": {
"title": "Activer Hydra", "title": "Activer Hydra",
@@ -237,7 +414,11 @@
"resume_seeding": "Reprendre le partage", "resume_seeding": "Reprendre le partage",
"options": "Gérer", "options": "Gérer",
"extract": "Extraire les fichiers", "extract": "Extraire les fichiers",
"extracting": "Extraction des fichiers…" "extracting": "Extraction des fichiers…",
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
"delete_archive_description": "Le fichier a été extrait avec succès et nest plus nécessaire.",
"yes": "Oui",
"no": "Non"
}, },
"settings": { "settings": {
"downloads_path": "Chemin des téléchargements", "downloads_path": "Chemin des téléchargements",
@@ -366,7 +547,40 @@
"bottom-left": "En bas à gauche", "bottom-left": "En bas à gauche",
"bottom-center": "En bas au centre", "bottom-center": "En bas au centre",
"bottom-right": "En bas à droite", "bottom-right": "En bas à droite",
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu" "enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu",
"adding": "Ajout…",
"failed_add_download_source": "Échec de lajout de la source de téléchargement. Veuillez réessayer.",
"download_source_already_exists": "Cette URL de source existe déjà",
"download_source_pending_matching": "Mise à jour imminente",
"download_source_matched": "À jour",
"download_source_matching": "Mise à jour",
"download_source_failed": "Erreur",
"download_source_no_information": "Aucune information disponible",
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
"importing": "Importation…",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"enable_steam_achievements": "Activer la recherche de succès Steam",
"alignment": "Alignement",
"variation": "Variation",
"default": "Par défaut",
"rare": "Rare",
"platinum": "Platine",
"hidden": "Caché",
"test_notification": "Notification de test",
"achievement_sound_volume": "Volume du son de succès",
"select_achievement_sound": "Sélectionner un son de succès",
"change_achievement_sound": "Changer le son de succès",
"remove_achievement_sound": "Supprimer le son de succès",
"preview_sound": "Prévisualiser le son",
"select": "Sélectionner",
"preview": "Aperçu",
"remove": "Supprimer",
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
"notification_preview": "Aperçu de la notification de succès",
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement dun jeu"
}, },
"notifications": { "notifications": {
"download_complete": "Téléchargement terminé", "download_complete": "Téléchargement terminé",

View File

@@ -22,10 +22,11 @@
"downloading": "{{title}} ({{percentage}} - Letöltés…)", "downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése", "filter": "Könyvtár szűrése",
"home": "Főoldal", "home": "Főoldal",
"queued": "A(z) {{title}} (Várakozósorban van)", "queued": "{{title}} (Várakozásban)",
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés", "sign_in": "Bejelentkezés",
"friends": "Barátok", "friends": "Barátok",
"notifications": "Értesítések",
"need_help": "Elakadtál?", "need_help": "Elakadtál?",
"favorites": "Kedvenc Játékaim", "favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd", "playable_button_title": "Csak az azonnal játszható játékokat mutasd",
@@ -94,6 +95,12 @@
"header": { "header": {
"search": "Keresés", "search": "Keresés",
"search_library": "Könyvtár böngészése", "search_library": "Könyvtár böngészése",
"recent_searches": "Korábbi Keresések",
"suggestions": "Találatok",
"clear_history": "Törlés",
"remove_from_history": "Törlés az előzményekből",
"loading": "Töltés...",
"no_results": "Nincs találat",
"home": "Főoldal", "home": "Főoldal",
"catalogue": "Katalógus", "catalogue": "Katalógus",
"library": "Könyvtár", "library": "Könyvtár",
@@ -109,6 +116,7 @@
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}", "downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…", "calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)", "checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Telepítés befejezve", "installation_complete": "Telepítés befejezve",
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve" "installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
@@ -165,8 +173,9 @@
"playing_now": "Játékban: ", "playing_now": "Játékban: ",
"change": "Változtatás", "change": "Változtatás",
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg", "select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
"download_now": "Letöltés", "download_now": "Letöltés",
"loading": "Töltés...",
"no_shop_details": "A bolt adatai nem érhetőek el.", "no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók", "download_options": "Letöltési opciók",
"download_path": "Letöltési hely", "download_path": "Letöltési hely",
@@ -175,7 +184,13 @@
"screenshot": "Screenshot {{number}}", "screenshot": "Screenshot {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ", "open_screenshot": "{{number}} Screenshot megnyitása ",
"download_settings": "Letöltési beállítások", "download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód", "downloader": "Letöltő",
"downloader_online": "Elérhető",
"downloader_not_configured": "Elérhető de nincs beállítva",
"downloader_offline": "A link nem elérhető",
"downloader_not_available": "Nem elérhető",
"recommended": "Ajánlott",
"go_to_settings": "Beállítások megnyitása",
"select_executable": "Tallózás", "select_executable": "Tallózás",
"no_executable_selected": "Nincs futtatható fájl tallózva", "no_executable_selected": "Nincs futtatható fájl tallózva",
"open_folder": "Mappa megnyitása", "open_folder": "Mappa megnyitása",
@@ -196,6 +211,7 @@
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve", "danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
"download_in_progress": "Letöltés folyamatban", "download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve", "download_paused": "Letöltés szüneteltetve",
"extracting": "Kicsomagolás",
"last_downloaded_option": "Utoljára letöltött", "last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új", "new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása", "create_steam_shortcut": "Steam parancsikon létrehozása",
@@ -397,7 +413,7 @@
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről", "delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
"install": "Telepít", "install": "Telepít",
"download_in_progress": "Folyamatban lévő", "download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések", "queued_downloads": "Várakozásban lévő letöltések",
"downloads_completed": "Befejezett", "downloads_completed": "Befejezett",
"queued": "Várakozásban", "queued": "Várakozásban",
"no_downloads_title": "Oly üres..", "no_downloads_title": "Oly üres..",
@@ -408,7 +424,13 @@
"resume_seeding": "Seedelés folytatása", "resume_seeding": "Seedelés folytatása",
"options": "Kezelés", "options": "Kezelés",
"extract": "Fájlok kibontása", "extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…" "extracting": "Fájlok kibontása…",
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
"delete_archive_description": "A tömörített fájl ki lett csomagolva és többé nincs rá szükség.",
"yes": "Igen",
"no": "Nem",
"network": "HÁLÓZAT",
"peak": "CSÚCS"
}, },
"settings": { "settings": {
"downloads_path": "Letöltési útvonalak", "downloads_path": "Letöltési útvonalak",
@@ -432,7 +454,7 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése", "save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve", "changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból, ennek az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés", "validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás", "remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása", "add_download_source": "Forrás hozáadása",
@@ -544,6 +566,7 @@
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése", "show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
"extract_files_by_default": "Fájlok kicsomagolása letöltés után", "extract_files_by_default": "Fájlok kicsomagolása letöltés után",
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése", "enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
"enable_new_download_options_badges": "Új letöltési helyek",
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése", "achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
"top-left": "Bal felső sarok", "top-left": "Bal felső sarok",
"top-center": "Felső közép", "top-center": "Felső közép",
@@ -624,9 +647,9 @@
"sort_by": "Rendezés:", "sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek", "achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva", "played_recently": "Nemrég játszva",
"playtime": "Játszottidő", "playtime": "Játékidő",
"total_play_time": "Teljes játszottidő", "total_play_time": "Teljes játékidő",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"no_recent_activity_title": "Hmmm… itt semmi sincs", "no_recent_activity_title": "Hmmm… itt semmi sincs",
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!", "no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
"display_name": "Profilnév", "display_name": "Profilnév",
@@ -648,6 +671,7 @@
"sending": "Küldés..", "sending": "Küldés..",
"friend_request_sent": "Barátfelkérés elküldve", "friend_request_sent": "Barátfelkérés elküldve",
"friends": "Barátok", "friends": "Barátok",
"badges": "Kitűzők",
"friends_list": "Barát lista", "friends_list": "Barát lista",
"user_not_found": "Felhasználó nem találva", "user_not_found": "Felhasználó nem találva",
"block_user": "Felhasználó letiltása", "block_user": "Felhasználó letiltása",
@@ -658,18 +682,22 @@
"ignore_request": "Kérés ignorálása", "ignore_request": "Kérés ignorálása",
"cancel_request": "Kérés visszavonása", "cancel_request": "Kérés visszavonása",
"undo_friendship": "Barát eltávolítása", "undo_friendship": "Barát eltávolítása",
"friendship_removed": "Barát eltávolítva",
"request_accepted": "Barátfelkérés elfogadva", "request_accepted": "Barátfelkérés elfogadva",
"user_blocked_successfully": "Felhasználó sikeresen letiltva", "user_blocked_successfully": "Felhasználó sikeresen letiltva",
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}", "user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
"blocked_users": "Letiltott felhasználók", "blocked_users": "Letiltott felhasználók",
"unblock": "Tiltás feloldása", "unblock": "Tiltás feloldása",
"no_friends_added": "Nincs bejelölt barátod", "no_friends_added": "Nincs bejelölt barátod",
"view_all": "Összes megtekintése",
"load_more": "Több betöltése",
"loading": "Töltés..",
"pending": "Függőben", "pending": "Függőben",
"no_pending_invites": "Nincs függőben lévő barátfelkérésed", "no_pending_invites": "Nincs függőben lévő barátfelkérésed",
"no_blocked_users": "Nincs letiltott felhasználó", "no_blocked_users": "Nincs letiltott felhasználó",
"friend_code_copied": "Barát kód kimásolva", "friend_code_copied": "Barát kód kimásolva",
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}", "undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe", "privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
"locked_profile": "Ez a profil privát", "locked_profile": "Ez a profil privát",
"image_process_failure": "Hiba a kép feldolgozása közben", "image_process_failure": "Hiba a kép feldolgozása közben",
"required_field": "Ez a mező kötelező", "required_field": "Ez a mező kötelező",
@@ -687,6 +715,7 @@
"report_reason_other": "Egyéb", "report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve", "profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:", "your_friend_code": "A barát kódod:",
"copy_friend_code": "Barátkód kimásolása",
"upload_banner": "Borítókép feltöltése", "upload_banner": "Borítókép feltöltése",
"uploading_banner": "Borítókép feltöltése…", "uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve", "background_image_updated": "Borítókép frissítve",
@@ -706,10 +735,12 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"user_reviews": "Vélemények", "user_reviews": "Vélemények",
"delete_review": "Vélemény Törlése", "delete_review": "Vélemény Törlése",
"loading_reviews": "Vélemények betöltése..." "loading_reviews": "Vélemények betöltése...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Wrapped 2025 megtekintése",
"view_wrapped_button": "{{displayName}} Wrapped 2025 megtekintése"
}, },
"library": { "library": {
"library": "Könyvtár", "library": "Könyvtár",
@@ -727,7 +758,7 @@
"amount_minutes": "{{amount}} perc", "amount_minutes": "{{amount}} perc",
"amount_hours_short": "{{amount}}ó", "amount_hours_short": "{{amount}}ó",
"amount_minutes_short": "{{amount}}p", "amount_minutes_short": "{{amount}}p",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"all_games": "Összes Játék", "all_games": "Összes Játék",
"recently_played": "Nemrég Játszva", "recently_played": "Nemrég Játszva",
"favorites": "Kedvencek" "favorites": "Kedvencek"
@@ -760,5 +791,41 @@
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!", "hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
"learn_more": "Tudj meg többet", "learn_more": "Tudj meg többet",
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal" "debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
},
"notifications_page": {
"title": "Értesítések",
"mark_all_as_read": "Megjelölés olvasottként",
"clear_all": "Összes Törlése",
"loading": "Töltés..",
"empty_title": "Nincsenek értesítések",
"empty_description": "Már mindet láttad! Nézz vissza később az újdonságokért.",
"empty_filter_description": "Nincs értesítés ami megfelel ennek a szűrőnek.",
"filter_all": "Összes",
"filter_unread": "Olvasatlan",
"filter_friends": "Barátok",
"filter_badges": "Kitűzők",
"filter_upvotes": "Felpontok",
"filter_local": "Helyi",
"load_more": "Több betöltése",
"dismiss": "Eltüntetés",
"accept": "Elfogad",
"refuse": "Elutasít",
"notification": "Értesítés",
"friend_request_received_title": "Új barátkérelem!",
"friend_request_received_description": "{{displayName}} a barátod szeretne lenni",
"friend_request_accepted_title": "Barátkérelem elfogadva!",
"friend_request_accepted_description": "{{displayName}} elfogadta a barátkérelmed",
"badge_received_title": "Kaptál egy új kitűzőt!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "A véleményed a(z) {{gameTitle}} játékhoz felpont-ot kapott!",
"review_upvote_description": "A véleményed {{count}} új felpontot kapott",
"marked_all_as_read": "Összes értesítés olvasottnak jelölve",
"failed_to_mark_as_read": "Az értesítések olvasottnak jelölése nem sikerült",
"cleared_all": "Összes értesítés eltüntetve",
"failed_to_clear": "Az értesítések eltüntetése nem sikerült",
"failed_to_load": "Az értesítések betöltése nem sikerült",
"failed_to_dismiss": "Értesítés eltüntetése nem sikerült",
"friend_request_accepted": "Barátfelkérés elfogadva",
"friend_request_refused": "Barátfelkérés elutasítva"
} }
} }

View File

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

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Jogo não possui executável selecionado", "game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login", "sign_in": "Login",
"friends": "Amigos", "friends": "Amigos",
"notifications": "Notificações",
"need_help": "Precisa de ajuda?", "need_help": "Precisa de ajuda?",
"favorites": "Favoritos", "favorites": "Favoritos",
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora", "playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
@@ -115,6 +116,7 @@
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", "downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…", "calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
"checking_files": "Verificando arquivos de {{title}}…", "checking_files": "Verificando arquivos de {{title}}…",
"extracting": "Extraindo {{title}}… ({{percentage}} concluído)",
"installing_common_redist": "{{log}}…", "installing_common_redist": "{{log}}…",
"installation_complete": "Instalação concluída", "installation_complete": "Instalação concluída",
"installation_complete_message": "Componentes recomendados instalados com sucesso" "installation_complete_message": "Componentes recomendados instalados com sucesso"
@@ -162,6 +164,7 @@
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download", "download_now": "Iniciar download",
"loading": "Carregando...",
"no_shop_details": "Não foi possível obter os detalhes da loja.", "no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download", "download_options": "Opções de download",
"download_path": "Diretório de download", "download_path": "Diretório de download",
@@ -171,6 +174,12 @@
"open_screenshot": "Ver captura de tela {{number}}", "open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download", "download_settings": "Ajustes do download",
"downloader": "Downloader", "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", "select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado", "no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta", "open_folder": "Abrir pasta",
@@ -190,6 +199,7 @@
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra", "danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento", "download_in_progress": "Download em andamento",
"download_paused": "Download pausado", "download_paused": "Download pausado",
"extracting": "Extraindo",
"last_downloaded_option": "Última opção baixada", "last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo", "new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam", "create_steam_shortcut": "Criar atalho na Steam",
@@ -360,6 +370,7 @@
"show_translation": "Mostrar tradução", "show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})", "show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original", "hide_original": "Ocultar original",
"vote_failed": "Falha ao registrar seu voto. Por favor, tente novamente.",
"rating_count": "Avaliação", "rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado", "review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar", "show": "Mostrar",
@@ -382,6 +393,10 @@
"completed": "Concluído", "completed": "Concluído",
"removed": "Cancelado", "removed": "Cancelado",
"cancel": "Cancelar", "cancel": "Cancelar",
"cancel_download": "Cancelar download?",
"cancel_download_description": "Tem certeza de que deseja cancelar este download? Todos os arquivos baixados serão excluídos.",
"keep_downloading": "Não, continuar baixando",
"yes_cancel": "Sim, cancelar",
"filter": "Filtrar jogos baixados", "filter": "Filtrar jogos baixados",
"remove": "Remover", "remove": "Remover",
"downloading_metadata": "Baixando metadados…", "downloading_metadata": "Baixando metadados…",
@@ -402,7 +417,13 @@
"resume_seeding": "Semear", "resume_seeding": "Semear",
"options": "Gerenciar", "options": "Gerenciar",
"extract": "Extrair arquivos", "extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…" "extracting": "Extraindo arquivos…",
"delete_archive_title": "Deseja deletar {{fileName}}?",
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
"yes": "Sim",
"no": "Não",
"network": "REDE",
"peak": "PICO"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",
@@ -449,6 +470,7 @@
"download_sources_synced_successfully": "Fontes de download sincronizadas", "download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida", "removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas", "removed_download_sources": "Fontes removidas",
"removed_all_download_sources": "Todas as fontes de download removidas",
"cancel_button_confirmation_delete_all_sources": "Não", "cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo", "confirm_button_confirmation_delete_all_sources": "Sim, excluir tudo",
"title_confirmation_delete_all_sources": "Remover todas as fontes de download", "title_confirmation_delete_all_sources": "Remover todas as fontes de download",
@@ -474,6 +496,7 @@
"blocked_users": "Usuários bloqueados", "blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado", "user_unblocked": "Usuário desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada", "enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"hydra_cloud": "Hydra Cloud",
"launch_minimized": "Iniciar o Hydra minimizado", "launch_minimized": "Iniciar o Hydra minimizado",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado", "disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download", "seed_after_download_complete": "Semear após a conclusão do download",
@@ -536,6 +559,7 @@
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo", "show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download", "extract_files_by_default": "Extrair arquivos automaticamente após o download",
"enable_steam_achievements": "Habilitar busca por conquistas da Steam", "enable_steam_achievements": "Habilitar busca por conquistas da Steam",
"enable_new_download_options_badges": "Mostrar badges de novas opções de download",
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas", "enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
"top-left": "Superior esquerdo", "top-left": "Superior esquerdo",
"top-center": "Superior central", "top-center": "Superior central",
@@ -553,6 +577,9 @@
"test_notification": "Testar notificação", "test_notification": "Testar notificação",
"achievement_sound_volume": "Volume do som de conquista", "achievement_sound_volume": "Volume do som de conquista",
"select_achievement_sound": "Selecionar som de conquista", "select_achievement_sound": "Selecionar som de conquista",
"change_achievement_sound": "Alterar som de conquista",
"remove_achievement_sound": "Remover som de conquista",
"preview_sound": "Reproduzir som",
"select": "Selecionar", "select": "Selecionar",
"preview": "Reproduzir", "preview": "Reproduzir",
"remove": "Remover", "remove": "Remover",
@@ -560,7 +587,10 @@
"notification_preview": "Prévia da Notificação de Conquistas", "notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo" "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo",
"downloads": "Downloads",
"use_native_http_downloader": "Usar downloader HTTP nativo (experimental)",
"cannot_change_downloader_while_downloading": "Não é possível alterar esta configuração enquanto um download estiver em andamento"
}, },
"notifications": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@@ -646,6 +676,7 @@
"see_profile": "Ver perfil", "see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado", "friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos", "friends": "Amigos",
"badges": "Insígnias",
"add": "Adicionar", "add": "Adicionar",
"sending": "Enviando", "sending": "Enviando",
"friends_list": "Lista de amigos", "friends_list": "Lista de amigos",
@@ -658,12 +689,17 @@
"ignore_request": "Ignorar pedido", "ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido", "cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade", "undo_friendship": "Desfazer amizade",
"friendship_removed": "Amigo removido",
"request_accepted": "Pedido de amizade aceito", "request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso", "user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}", "user_block_modal_text": "Bloquear {{displayName}}",
"blocked_users": "Usuários bloqueados", "blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados", "no_friends_added": "Você ainda não possui amigos adicionados",
"no_friends_yet": "Você ainda não adicionou nenhum amigo",
"view_all": "Ver todos",
"load_more": "Carregar mais",
"loading": "Carregando",
"pending": "Pendentes", "pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes", "no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado", "no_blocked_users": "Você não tem nenhum usuário bloqueado",
@@ -687,8 +723,16 @@
"report_reason_other": "Outro", "report_reason_other": "Outro",
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:", "your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"upload_banner": "Carregar banner", "upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…", "uploading_banner": "Carregando banner…",
"change_banner": "Alterar banner",
"replace_banner": "Substituir banner",
"remove_banner": "Remover banner",
"remove_banner_modal_title": "Remover banner?",
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
"remove": "Remover",
"background_image_updated": "Imagem de fundo salva", "background_image_updated": "Imagem de fundo salva",
"stats": "Estatísticas", "stats": "Estatísticas",
"achievements": "conquistas", "achievements": "conquistas",
@@ -712,10 +756,10 @@
"achievements_earned": "Conquistas recebidas", "achievements_earned": "Conquistas recebidas",
"karma": "Karma", "karma": "Karma",
"karma_count": "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", "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"user_reviews": "Avaliações", "user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...", "loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Ainda não há avaliações", "no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação" "delete_review": "Excluir avaliação"
}, },
@@ -768,5 +812,41 @@
"all_games": "Todos os Jogos", "all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente", "recently_played": "Jogados Recentemente",
"favorites": "Favoritos" "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", "show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
"animated_profile_banner": "Banner animado no perfil", "animated_profile_banner": "Banner animado no perfil",
"cloud_saving": "Progresso dos jogos na nuvem", "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" "learn_more": "Saber mais"
} }
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import fs from "node:fs";
import { registerEvent } from "../register-event";
import { logger } from "@main/services";
const deleteArchive = async (
_event: Electron.IpcMainInvokeEvent,
filePath: string
) => {
try {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
logger.info(`Deleted archive: ${filePath}`);
return true;
}
return true;
} catch (err) {
logger.error(`Failed to delete archive: ${filePath}`, err);
return false;
}
};
registerEvent("deleteArchive", deleteArchive);

View File

@@ -22,6 +22,7 @@ const extractGameDownload = async (
await downloadsSublevel.put(gameKey, { await downloadsSublevel.put(gameKey, {
...download, ...download,
extracting: true, extracting: true,
extractionProgress: 0,
}); });
const gameFilesManager = new GameFilesManager(shop, objectId); const gameFilesManager = new GameFilesManager(shop, objectId);

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

@@ -8,10 +8,12 @@ import "./close-game";
import "./copy-custom-game-asset"; import "./copy-custom-game-asset";
import "./create-game-shortcut"; import "./create-game-shortcut";
import "./create-steam-shortcut"; import "./create-steam-shortcut";
import "./delete-archive";
import "./delete-game-folder"; import "./delete-game-folder";
import "./extract-game-download"; import "./extract-game-download";
import "./get-default-wine-prefix-selection-path"; import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id"; import "./get-game-by-object-id";
import "./get-game-installer-action-type";
import "./get-library"; import "./get-library";
import "./open-game-executable-path"; import "./open-game-executable-path";
import "./open-game-installer-path"; import "./open-game-installer-path";

View File

@@ -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 "./publish-new-repacks-notification";
import "./show-achievement-test-notification"; import "./show-achievement-test-notification";
import "./update-achievement-notification-window"; 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 "./get-me";
import "./process-profile-image"; import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile"; 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", "backgroundImageUrl",
]); ]);
if (updateProfile.profileImageUrl) { if (updateProfile.profileImageUrl !== undefined) {
const profileImageUrl = await uploadImage( if (updateProfile.profileImageUrl === null) {
"profile-image", payload["profileImageUrl"] = null;
updateProfile.profileImageUrl } else {
).catch(() => undefined); const profileImageUrl = await uploadImage(
"profile-image",
updateProfile.profileImageUrl
).catch(() => undefined);
payload["profileImageUrl"] = profileImageUrl; payload["profileImageUrl"] = profileImageUrl;
}
} }
if (updateProfile.backgroundImageUrl) { if (updateProfile.backgroundImageUrl !== undefined) {
const backgroundImageUrl = await uploadImage( if (updateProfile.backgroundImageUrl === null) {
"background-image", payload["backgroundImageUrl"] = null;
updateProfile.backgroundImageUrl } else {
).catch(() => undefined); const backgroundImageUrl = await uploadImage(
"background-image",
updateProfile.backgroundImageUrl
).catch(() => undefined);
payload["backgroundImageUrl"] = backgroundImageUrl; payload["backgroundImageUrl"] = backgroundImageUrl;
}
} }
return patchUserProfile(payload); return patchUserProfile(payload);

View File

@@ -41,7 +41,6 @@ const startGameDownload = async (
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey); const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey); await downloadsSublevel.del(gameKey);
if (game) { if (game) {
@@ -82,6 +81,7 @@ const startGameDownload = async (
queued: true, queued: true,
extracting: false, extracting: false,
automaticallyExtract, automaticallyExtract,
extractionProgress: 0,
}; };
try { try {
@@ -123,6 +123,42 @@ const startGameDownload = async (
} }
if (err instanceof Error) { if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "Buzzheavier: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "Buzzheavier: File not found",
};
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "FuckingFast: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "FuckingFast: File not found",
};
}
}
return { ok: false, error: err.message }; return { ok: false, error: err.message };
} }

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) // @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable // tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/ */
export interface FriendRequest { export interface FriendRequest {
/** /**
* @generated from protobuf field: int32 friend_request_count = 1; * @generated from protobuf field: int32 friend_request_count = 1
*/ */
friendRequestCount: number; friendRequestCount: number;
/** /**
* @generated from protobuf field: optional string sender_id = 2; * @generated from protobuf field: optional string sender_id = 2
*/ */
senderId?: string; senderId?: string;
} }
@@ -28,18 +28,27 @@ export interface FriendRequest {
*/ */
export interface FriendGameSession { export interface FriendGameSession {
/** /**
* @generated from protobuf field: string object_id = 1; * @generated from protobuf field: string object_id = 1
*/ */
objectId: string; objectId: string;
/** /**
* @generated from protobuf field: string shop = 2; * @generated from protobuf field: string shop = 2
*/ */
shop: string; shop: string;
/** /**
* @generated from protobuf field: string friend_id = 3; * @generated from protobuf field: string friend_id = 3
*/ */
friendId: string; 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 * @generated from protobuf message Envelope
*/ */
@@ -51,17 +60,24 @@ export interface Envelope {
| { | {
oneofKind: "friendRequest"; oneofKind: "friendRequest";
/** /**
* @generated from protobuf field: FriendRequest friend_request = 1; * @generated from protobuf field: FriendRequest friend_request = 1
*/ */
friendRequest: FriendRequest; friendRequest: FriendRequest;
} }
| { | {
oneofKind: "friendGameSession"; oneofKind: "friendGameSession";
/** /**
* @generated from protobuf field: FriendGameSession friend_game_session = 2; * @generated from protobuf field: FriendGameSession friend_game_session = 2
*/ */
friendGameSession: FriendGameSession; friendGameSession: FriendGameSession;
} }
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| { | {
oneofKind: undefined; oneofKind: undefined;
}; };
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/ */
export const FriendGameSession = new FriendGameSession$Type(); export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods // @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> { class Envelope$Type extends MessageType<Envelope> {
constructor() { constructor() {
super("Envelope", [ super("Envelope", [
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload", oneof: "payload",
T: () => FriendGameSession, T: () => FriendGameSession,
}, },
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]); ]);
} }
create(value?: PartialMessage<Envelope>): Envelope { create(value?: PartialMessage<Envelope>): Envelope {
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
), ),
}; };
break; break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default: default:
let u = options.readUnknownField; let u = options.readUnknownField;
if (u === "throw") if (u === "throw")
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(), writer.tag(2, WireType.LengthDelimited).fork(),
options options
).join(); ).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; let u = options.writeUnknownFields;
if (u !== false) if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)( (u == true ? UnknownFieldHandler.onWrite : u)(

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { app } from "electron"; import { app } from "electron";
import cp from "node:child_process"; import Seven, { CommandLineSwitches } from "node-7z";
import path from "node:path"; import path from "node:path";
import { logger } from "./logger"; import { logger } from "./logger";
@@ -9,6 +9,17 @@ export const binaryName = {
win32: "7z.exe", win32: "7z.exe",
}; };
export interface ExtractionProgress {
percent: number;
fileCount: number;
file: string;
}
export interface ExtractionResult {
success: boolean;
extractedFiles: string[];
}
export class SevenZip { export class SevenZip {
private static readonly binaryPath = app.isPackaged private static readonly binaryPath = app.isPackaged
? path.join(process.resourcesPath, binaryName[process.platform]) ? path.join(process.resourcesPath, binaryName[process.platform])
@@ -32,43 +43,109 @@ export class SevenZip {
cwd?: string; cwd?: string;
passwords?: string[]; passwords?: string[];
}, },
successCb: () => void, onProgress?: (progress: ExtractionProgress) => void
errorCb: () => void ): Promise<ExtractionResult> {
) { return new Promise((resolve, reject) => {
const tryPassword = (index = -1) => { const tryPassword = (index = 0) => {
const password = passwords[index] ?? ""; const password = passwords[index] ?? "";
logger.info(`Trying password ${password} on ${filePath}`); logger.info(
`Trying password "${password || "(empty)"}" on ${filePath}`
);
const args = ["x", filePath, "-y", "-p" + password]; const extractedFiles: string[] = [];
let fileCount = 0;
if (outputPath) { const options: CommandLineSwitches = {
args.push("-o" + outputPath); $bin: this.binaryPath,
} $progress: true,
yes: true,
password: password || undefined,
};
const child = cp.execFile(this.binaryPath, args, { if (outputPath) {
cwd, options.outputDir = outputPath;
});
child.once("exit", (code) => {
if (code === 0) {
successCb();
return;
} }
if (index < passwords.length - 1) { const stream = Seven.extractFull(filePath, outputPath || cwd || ".", {
...options,
$spawnOptions: cwd ? { cwd } : undefined,
});
stream.on("progress", (progress) => {
if (onProgress) {
onProgress({
percent: progress.percent,
fileCount: fileCount,
file: progress.fileCount?.toString() || "",
});
}
});
stream.on("data", (data) => {
if (data.file) {
extractedFiles.push(data.file);
fileCount++;
}
});
stream.on("end", () => {
logger.info( logger.info(
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...` `Successfully extracted ${filePath} (${extractedFiles.length} files)`
); );
resolve({
success: true,
extractedFiles,
});
});
tryPassword(index + 1); stream.on("error", (err) => {
} else { logger.error(`Extraction error for ${filePath}:`, err);
logger.info(`Failed to extract file: ${filePath}`);
errorCb(); if (index < passwords.length - 1) {
logger.info(
`Failed to extract file: ${filePath} with password: "${password}". Trying next password...`
);
tryPassword(index + 1);
} else {
logger.error(
`Failed to extract file: ${filePath} after trying all passwords`
);
reject(new Error(`Failed to extract file: ${filePath}`));
}
});
};
tryPassword(0);
});
}
public static listFiles(
filePath: string,
password?: string
): Promise<string[]> {
return new Promise((resolve, reject) => {
const files: string[] = [];
const options: CommandLineSwitches = {
$bin: this.binaryPath,
password: password || undefined,
};
const stream = Seven.list(filePath, options);
stream.on("data", (data) => {
if (data.file) {
files.push(data.file);
} }
}); });
};
tryPassword(); stream.on("end", () => {
resolve(files);
});
stream.on("error", (err) => {
reject(err);
});
});
} }
} }

View File

@@ -74,21 +74,16 @@ export class DeckyPlugin {
await fs.promises.mkdir(extractPath, { recursive: true }); await fs.promises.mkdir(extractPath, { recursive: true });
return new Promise((resolve, reject) => { try {
SevenZip.extractFile( await SevenZip.extractFile({
{ filePath: zipPath,
filePath: zipPath, outputPath: extractPath,
outputPath: extractPath, });
}, logger.log(`Plugin extracted to: ${extractPath}`);
() => { return extractPath;
logger.log(`Plugin extracted to: ${extractPath}`); } catch {
resolve(extractPath); throw new Error("Failed to extract plugin");
}, }
() => {
reject(new Error("Failed to extract plugin"));
}
);
});
} }
private static needsSudo(): boolean { private static needsSudo(): boolean {

View File

@@ -5,10 +5,12 @@ import {
updateDownloadSourcesCheckBaseline, updateDownloadSourcesCheckBaseline,
updateDownloadSourcesSinceValue, updateDownloadSourcesSinceValue,
downloadSourcesSublevel, downloadSourcesSublevel,
db,
levelKeys,
} from "@main/level"; } from "@main/level";
import { logger } from "./logger"; import { logger } from "./logger";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import type { Game } from "@types"; import type { Game, UserPreferences } from "@types";
interface DownloadSourcesChangeResponse { interface DownloadSourcesChangeResponse {
shop: string; shop: string;
@@ -101,6 +103,20 @@ export class DownloadSourcesChecker {
logger.info("DownloadSourcesChecker.checkForChanges() called"); logger.info("DownloadSourcesChecker.checkForChanges() called");
try { 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) // Get all installed games (excluding custom games)
const installedGames = await gamesSublevel.values().all(); const installedGames = await gamesSublevel.values().all();
const nonCustomGames = installedGames.filter( const nonCustomGames = installedGames.filter(

View File

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

View File

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

View File

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

View File

@@ -3,24 +3,58 @@ import fs from "node:fs";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
import { SevenZip } from "./7zip"; import { SevenZip, ExtractionProgress } from "./7zip";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { publishExtractionCompleteNotification } from "./notifications"; import { publishExtractionCompleteNotification } from "./notifications";
import { logger } from "./logger"; import { logger } from "./logger";
const PROGRESS_THROTTLE_MS = 1000;
export class GameFilesManager { export class GameFilesManager {
private lastProgressUpdate = 0;
constructor( constructor(
private readonly shop: GameShop, private readonly shop: GameShop,
private readonly objectId: string private readonly objectId: string
) {} ) {}
private async clearExtractionState() { private get gameKey() {
const gameKey = levelKeys.game(this.shop, this.objectId); return levelKeys.game(this.shop, this.objectId);
const download = await downloadsSublevel.get(gameKey); }
await downloadsSublevel.put(gameKey, { private async updateExtractionProgress(progress: number, force = false) {
...download!, const now = Date.now();
if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) {
return;
}
this.lastProgressUpdate = now;
const download = await downloadsSublevel.get(this.gameKey);
if (!download) return;
await downloadsSublevel.put(this.gameKey, {
...download,
extractionProgress: progress,
});
WindowManager.mainWindow?.webContents.send(
"on-extraction-progress",
this.shop,
this.objectId,
progress
);
}
private async clearExtractionState() {
const download = await downloadsSublevel.get(this.gameKey);
if (!download) return;
await downloadsSublevel.put(this.gameKey, {
...download,
extracting: false, extracting: false,
extractionProgress: 0,
}); });
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
@@ -30,6 +64,10 @@ export class GameFilesManager {
); );
} }
private readonly handleProgress = (progress: ExtractionProgress) => {
this.updateExtractionProgress(progress.percent / 100);
};
async extractFilesInDirectory(directoryPath: string) { async extractFilesInDirectory(directoryPath: string) {
if (!fs.existsSync(directoryPath)) return; if (!fs.existsSync(directoryPath)) return;
const files = await fs.promises.readdir(directoryPath); const files = await fs.promises.readdir(directoryPath);
@@ -42,53 +80,66 @@ export class GameFilesManager {
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file) (file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
); );
await Promise.all( if (filesToExtract.length === 0) return;
filesToExtract.map((file) => {
return new Promise((resolve, reject) => {
SevenZip.extractFile(
{
filePath: path.join(directoryPath, file),
cwd: directoryPath,
passwords: ["online-fix.me", "steamrip.com"],
},
() => {
resolve(true);
},
() => {
reject(new Error(`Failed to extract file: ${file}`));
this.clearExtractionState();
}
);
});
})
);
compressedFiles.forEach((file) => { await this.updateExtractionProgress(0, true);
const extractionPath = path.join(directoryPath, file);
if (fs.existsSync(extractionPath)) { const totalFiles = filesToExtract.length;
fs.unlink(extractionPath, (err) => { let completedFiles = 0;
if (err) {
logger.error(`Failed to delete file: ${file}`, err);
this.clearExtractionState(); for (const file of filesToExtract) {
try {
const result = await SevenZip.extractFile(
{
filePath: path.join(directoryPath, file),
cwd: directoryPath,
passwords: ["online-fix.me", "steamrip.com"],
},
(progress) => {
const overallProgress =
(completedFiles + progress.percent / 100) / totalFiles;
this.updateExtractionProgress(overallProgress);
} }
}); );
if (result.success) {
completedFiles++;
await this.updateExtractionProgress(
completedFiles / totalFiles,
true
);
}
} catch (err) {
logger.error(`Failed to extract file: ${file}`, err);
await this.clearExtractionState();
return;
} }
}); }
const archivePaths = compressedFiles
.map((file) => path.join(directoryPath, file))
.filter((archivePath) => fs.existsSync(archivePath));
if (archivePaths.length > 0) {
WindowManager.mainWindow?.webContents.send(
"on-archive-deletion-prompt",
archivePaths
);
}
} }
async setExtractionComplete(publishNotification = true) { async setExtractionComplete(publishNotification = true) {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([ const [download, game] = await Promise.all([
downloadsSublevel.get(gameKey), downloadsSublevel.get(this.gameKey),
gamesSublevel.get(gameKey), gamesSublevel.get(this.gameKey),
]); ]);
await downloadsSublevel.put(gameKey, { if (!download) return;
...download!,
await downloadsSublevel.put(this.gameKey, {
...download,
extracting: false, extracting: false,
extractionProgress: 0,
}); });
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
@@ -97,17 +148,15 @@ export class GameFilesManager {
this.objectId this.objectId
); );
if (publishNotification) { if (publishNotification && game) {
publishExtractionCompleteNotification(game!); publishExtractionCompleteNotification(game);
} }
} }
async extractDownloadedFile() { async extractDownloadedFile() {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([ const [download, game] = await Promise.all([
downloadsSublevel.get(gameKey), downloadsSublevel.get(this.gameKey),
gamesSublevel.get(gameKey), gamesSublevel.get(this.gameKey),
]); ]);
if (!download || !game) return false; if (!download || !game) return false;
@@ -119,39 +168,39 @@ export class GameFilesManager {
path.parse(download.folderName!).name path.parse(download.folderName!).name
); );
SevenZip.extractFile( await this.updateExtractionProgress(0, true);
{
filePath, try {
outputPath: extractionPath, const result = await SevenZip.extractFile(
passwords: ["online-fix.me", "steamrip.com"], {
}, filePath,
async () => { outputPath: extractionPath,
passwords: ["online-fix.me", "steamrip.com"],
},
this.handleProgress
);
if (result.success) {
await this.extractFilesInDirectory(extractionPath); await this.extractFilesInDirectory(extractionPath);
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
fs.unlink(filePath, (err) => { WindowManager.mainWindow?.webContents.send(
if (err) { "on-archive-deletion-prompt",
logger.error( [filePath]
`Failed to delete file: ${download.folderName}`, );
err
);
this.clearExtractionState();
}
});
} }
await downloadsSublevel.put(gameKey, { await downloadsSublevel.put(this.gameKey, {
...download!, ...download,
folderName: path.parse(download.folderName!).name, folderName: path.parse(download.folderName!).name,
}); });
this.setExtractionComplete(); await this.setExtractionComplete();
},
() => {
this.clearExtractionState();
} }
); } catch (err) {
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
await this.clearExtractionState();
}
return true; return true;
} }

View File

@@ -0,0 +1,100 @@
import axios from "axios";
import http from "node:http";
import https from "node:https";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
handleHosterError,
} from "./fuckingfast";
import { logger } from "@main/services";
export class BuzzheavierApi {
private static readonly BUZZHEAVIER_DOMAINS = [
"buzzheavier.com",
"bzzhr.co",
"fuckingfast.net",
];
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
);
await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
});
const downloadUrl = `${baseUrl}/download`;
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
const headResponse = await axios.head(downloadUrl, {
headers: {
"hx-current-url": baseUrl,
"hx-request": "true",
referer: baseUrl,
"User-Agent": HOSTER_USER_AGENT,
},
maxRedirects: 0,
validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
});
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
);
throw new Error(
"Could not extract download link. File may be deleted or is a directory."
);
}
const domain = new URL(baseUrl).hostname;
const directLink = hxRedirect.startsWith("/dl/")
? `https://${domain}${hxRedirect}`
: hxRedirect;
logger.log(`[Buzzheavier] Extracted direct link`);
return directLink;
} catch (error) {
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
);
}
return this.getBuzzheavierDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -0,0 +1,129 @@
import axios from "axios";
import { logger } from "@main/services";
export const HOSTER_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
export async function extractHosterFilename(
url: string,
directUrl?: string
): Promise<string> {
if (url.includes("#")) {
const fragment = url.split("#")[1];
if (fragment && !fragment.startsWith("http")) {
return fragment;
}
}
if (directUrl) {
try {
const response = await axios.head(directUrl, {
timeout: 10000,
headers: { "User-Agent": HOSTER_USER_AGENT },
});
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].replace(/['"]/g, "");
}
}
} catch {
// Ignore errors
}
const urlPath = new URL(directUrl).pathname;
const filename = urlPath.split("/").pop()?.split("?")[0];
if (filename) {
return filename;
}
}
return "downloaded_file";
}
export function handleHosterError(error: unknown): never {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new Error("File not found");
}
if (error.response?.status === 429) {
throw new Error("Rate limit exceeded. Please try again later.");
}
if (error.response?.status === 403) {
throw new Error("Access denied. File may be private or deleted.");
}
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
}
throw error;
}
// ============================================
// FuckingFast API Class
// ============================================
export class FuckingFastApi {
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
private static readonly FUCKINGFAST_REGEX =
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getFuckingFastDirectLink(url: string): Promise<string> {
try {
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
const response = await axios.get(url, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const html = response.data;
if (html.toLowerCase().includes("rate limit")) {
logger.error(`[FuckingFast] Rate limit detected`);
throw new Error(
"Rate limit exceeded. Please wait a few minutes and try again."
);
}
if (html.includes("File Not Found Or Deleted")) {
logger.error(`[FuckingFast] File not found or deleted`);
throw new Error("File not found or deleted");
}
const match = this.FUCKINGFAST_REGEX.exec(html);
if (!match || !match[1]) {
logger.error(`[FuckingFast] Could not extract download link`);
throw new Error("Could not extract download link from page");
}
logger.log(`[FuckingFast] Successfully extracted direct link`);
return match[1];
} catch (error) {
logger.error(`[FuckingFast] Error:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
);
}
return this.getFuckingFastDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -1,5 +1,8 @@
export * from "./gofile"; export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes"; export * from "./datanodes";
export * from "./mediafire"; export * from "./mediafire";
export * from "./pixeldrain"; export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./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 instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes 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) { private static secondsToMilliseconds(seconds: number) {
return seconds * 1000; return seconds * 1000;

View File

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

87
src/main/services/node-7z.d.ts vendored Normal file
View File

@@ -0,0 +1,87 @@
declare module "node-7z" {
import { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
export interface CommandLineSwitches {
$bin?: string;
$progress?: boolean;
$spawnOptions?: {
cwd?: string;
};
outputDir?: string;
yes?: boolean;
password?: string;
[key: string]: unknown;
}
export interface ProgressInfo {
percent: number;
fileCount?: number;
}
export interface FileInfo {
file?: string;
[key: string]: unknown;
}
export interface ZipStream extends EventEmitter {
on(event: "progress", listener: (progress: ProgressInfo) => void): this;
on(event: "data", listener: (data: FileInfo) => void): this;
on(event: "end", listener: () => void): this;
on(event: "error", listener: (err: Error) => void): this;
info: Map<string, unknown>;
_childProcess?: ChildProcess;
}
export function extractFull(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function extract(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function list(
archive: string,
options?: CommandLineSwitches
): ZipStream;
export function add(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function update(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function deleteFiles(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function test(
archive: string,
options?: CommandLineSwitches
): ZipStream;
const Seven: {
extractFull: typeof extractFull;
extract: typeof extract;
list: typeof list;
add: typeof add;
update: typeof update;
delete: typeof deleteFiles;
test: typeof test;
};
export default Seven;
}

View File

@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
import { SystemPath } from "../system-path"; import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers"; import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image"; import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => { const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg") 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) { if (userPreferences?.downloadNotificationsEnabled) {
new Notification({ new Notification({
title: t("download_complete", { title,
ns: "notifications", body,
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
icon: await downloadImage(game.iconUrl), icon: await downloadImage(game.iconUrl),
}).show(); }).show();
} }
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
}; };
export const publishNotificationUpdateReadyToInstall = async ( export const publishNotificationUpdateReadyToInstall = async (
version: string version: string
) => { ) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({ new Notification({
title: t("new_update_available", { title,
ns: "notifications", body,
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon, icon: trayIcon,
}) })
.on("click", () => { .on("click", () => {
restartAndInstallUpdate(); restartAndInstallUpdate();
}) })
.show(); .show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
}; };
export const publishNewFriendRequestNotification = async ( export const publishNewFriendRequestNotification = async (
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
}; };
export const publishExtractionCompleteNotification = async (game: Game) => { 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({ new Notification({
title: t("extraction_complete", { ns: "notifications" }), title,
body: t("game_extracted", { body,
ns: "notifications",
title: game.title,
}),
icon: trayIcon, icon: trayIcon,
}).show(); }).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
}; };
export const publishNewAchievementNotification = async (info: { 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 axios from "axios";
import http from "node:http";
import cp from "node:child_process"; import cp from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
@@ -31,6 +32,9 @@ export class PythonRPC {
public static readonly RPC_PORT = "8084"; public static readonly RPC_PORT = "8084";
public static readonly rpc = axios.create({ public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`, baseURL: `http://localhost:${this.RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
}); });
private static pythonProcess: cp.ChildProcess | null = null; private static pythonProcess: cp.ChildProcess | null = null;

View File

@@ -36,9 +36,9 @@ export class WindowManager {
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions = private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{ {
width: 1200, width: 1200,
height: 720, height: 860,
minWidth: 1024, minWidth: 1024,
minHeight: 540, minHeight: 860,
backgroundColor: "#1c1c1c", backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden", titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon, icon,
@@ -106,7 +106,7 @@ export class WindowManager {
valueEncoding: "json", valueEncoding: "json",
} }
); );
return data ?? { isMaximized: false, height: 720, width: 1200 }; return data ?? { isMaximized: false, height: 860, width: 1200 };
} }
private static updateInitialConfig( private static updateInitialConfig(
@@ -224,7 +224,7 @@ export class WindowManager {
? { ? {
x: undefined, x: undefined,
y: undefined, y: undefined,
height: this.initialConfigInitializationMainWindow.height ?? 720, height: this.initialConfigInitializationMainWindow.height ?? 860,
width: this.initialConfigInitializationMainWindow.width ?? 1200, width: this.initialConfigInitializationMainWindow.width ?? 1200,
isMaximized: true, isMaximized: true,
} }

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 { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request"; import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session"; import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient { export class WSClient {
private static ws: WebSocket | null = null; private static ws: WebSocket | null = null;
@@ -51,6 +52,10 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") { if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession); friendGameSessionEvent(envelope.payload.friendGameSession);
} }
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
}); });
this.ws.on("close", () => this.handleDisconnect("close")); this.ws.on("close", () => this.handleDisconnect("close"));

View File

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

View File

@@ -15,6 +15,7 @@ import type {
GameAchievement, GameAchievement,
Theme, Theme,
FriendRequestSync, FriendRequestSync,
NotificationSync,
ShortcutLocation, ShortcutLocation,
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
AchievementNotificationInfo, AchievementNotificationInfo,
@@ -205,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) => openGameInstallerPath: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstallerPath", shop, objectId), ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
openGameExecutablePath: (shop: GameShop, objectId: string) => openGameExecutablePath: (shop: GameShop, objectId: string) =>
@@ -267,6 +270,29 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-extraction-complete", listener); ipcRenderer.on("on-extraction-complete", listener);
return () => ipcRenderer.removeListener("on-extraction-complete", listener); return () => ipcRenderer.removeListener("on-extraction-complete", listener);
}, },
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
shop: GameShop,
objectId: string,
progress: number
) => cb(shop, objectId, progress);
ipcRenderer.on("on-extraction-progress", listener);
return () => ipcRenderer.removeListener("on-extraction-progress", listener);
},
onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
archivePaths: string[]
) => cb(archivePaths);
ipcRenderer.on("on-archive-deletion-prompt", listener);
return () =>
ipcRenderer.removeListener("on-archive-deletion-prompt", listener);
},
deleteArchive: (filePath: string) =>
ipcRenderer.invoke("deleteArchive", filePath),
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => getDiskFreeSpace: (path: string) =>
@@ -474,7 +500,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateProfile", updateProfile), ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) => processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath), ipcRenderer.invoke("processProfileImage", imagePath),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => { onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = ( const listener = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
@@ -484,6 +509,15 @@ contextBridge.exposeInMainWorld("electron", {
return () => return () =>
ipcRenderer.removeListener("on-sync-friend-requests", listener); 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) => updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action), ipcRenderer.invoke("updateFriendRequest", userId, action),
@@ -527,6 +561,26 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), 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: ( onAchievementUnlocked: (
cb: ( cb: (
position?: AchievementCustomNotificationPosition, position?: AchievementCustomNotificationPosition,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import { import {
useAppDispatch, useAppDispatch,
useAppSelector, useAppSelector,
@@ -19,11 +19,13 @@ import {
setUserDetails, setUserDetails,
setProfileBackground, setProfileBackground,
setGameRunning, setGameRunning,
setExtractionProgress,
clearExtraction,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
import { import {
injectCustomCss, injectCustomCss,
@@ -50,13 +52,10 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWondersSdk | null>(null);
const { const {
userDetails,
hasActiveSubscription, hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
updateUserDetails, updateUserDetails,
clearUserDetails, clearUserDetails,
@@ -78,6 +77,10 @@ export function App() {
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
levelDBService.get("userPreferences", null, "json"), levelDBService.get("userPreferences", null, "json"),
@@ -113,7 +116,30 @@ export function App() {
return () => unsubscribe(); return () => unsubscribe();
}, [updateLibrary]); }, [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"); const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) { if (cachedUserDetails) {
@@ -124,28 +150,31 @@ export function App() {
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
} }
fetchUserDetails() const userPreferences = await window.electron.getUserPreferences();
.then((response) => { const userDetails = await fetchUserDetails().catch(() => null);
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
}
})
.finally(() => {
if (document.getElementById("external-resources")) return;
const $script = document.createElement("script"); if (userDetails) {
$script.id = "external-resources"; updateUserDetails(userDetails);
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`; }
document.head.appendChild($script);
}); setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
}, [fetchUserDetails, updateUserDetails, dispatch]);
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(() => { const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@@ -184,15 +213,27 @@ export function App() {
updateLibrary(); updateLibrary();
}), }),
window.electron.onSignOut(() => clearUserDetails()), window.electron.onSignOut(() => clearUserDetails()),
window.electron.onExtractionProgress((shop, objectId, progress) => {
dispatch(setExtractionProgress({ shop, objectId, progress }));
}),
window.electron.onExtractionComplete(() => {
dispatch(clearExtraction());
updateLibrary();
}),
window.electron.onArchiveDeletionPrompt((paths) => {
setArchivePaths(paths);
setShowArchiveDeletionModal(true);
}),
]; ];
return () => { return () => {
listeners.forEach((unsubscribe) => unsubscribe()); listeners.forEach((unsubscribe) => unsubscribe());
}; };
}, [onSignIn, updateLibrary, clearUserDetails]); }, [onSignIn, updateLibrary, clearUserDetails, dispatch]);
useEffect(() => { useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0; if (contentRef.current) contentRef.current.scrollTop = 0;
workwondersRef.current?.notifyUrlChange();
}, [location.pathname, location.search]); }, [location.pathname, location.search]);
useEffect(() => { useEffect(() => {
@@ -281,14 +322,11 @@ export function App() {
feature={hydraCloudFeature} feature={hydraCloudFeature}
/> />
{userDetails && ( <ArchiveDeletionModal
<UserFriendModal visible={showArchiveDeletionModal}
visible={isFriendsModalVisible} archivePaths={archivePaths}
initialTab={friendRequetsModalTab} onClose={() => setShowArchiveDeletionModal(false)}
onClose={hideFriendsModal} />
userId={friendModalUserId}
/>
)}
<main> <main>
<Sidebar /> <Sidebar />

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
useAppSelector,
useDownload, useDownload,
useLibrary, useLibrary,
useToast, useToast,
@@ -26,6 +27,8 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload(); const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>(""); const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>( const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -68,6 +71,20 @@ export function BottomPanel() {
return t("installing_common_redist", { log: commonRedistStatus }); return t("installing_common_redist", { log: commonRedistStatus });
} }
if (extraction) {
const extractingGame = library.find(
(game) => game.id === extraction.visibleId
);
if (extractingGame) {
const extractionPercentage = Math.round(extraction.progress * 100);
return t("extracting", {
title: extractingGame.title,
percentage: `${extractionPercentage}%`,
});
}
}
const game = lastPacket const game = lastPacket
? library.find((game) => game.id === lastPacket?.gameId) ? library.find((game) => game.id === lastPacket?.gameId)
: undefined; : undefined;
@@ -109,6 +126,7 @@ export function BottomPanel() {
eta, eta,
downloadSpeed, downloadSpeed,
commonRedistStatus, commonRedistStatus,
extraction,
]); ]);
return ( return (

View File

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

View File

@@ -0,0 +1,82 @@
@use "../../scss/globals.scss";
.fullscreen-media-modal__overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(2px);
z-index: globals.$backdrop-z-index;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
.fullscreen-media-modal {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: relative;
margin: 0;
padding: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 4);
cursor: pointer;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border: 1px solid globals.$border-color;
padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
z-index: 10;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
&__image-container {
max-width: 90%;
max-height: 90%;
display: flex;
justify-content: center;
align-items: center;
}
&__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes image-appear {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./fullscreen-media-modal.scss";
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
src: string | null | undefined;
alt?: string;
}
export function FullscreenMediaModal({
visible,
onClose,
src,
alt,
}: FullscreenMediaModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("modal");
useEffect(() => {
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [onClose, visible]);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (containerRef.current) {
const clickedOnImage = containerRef.current.contains(e.target as Node);
if (!clickedOnImage) {
onClose();
}
}
};
if (visible) {
window.addEventListener("mousedown", onMouseDown);
}
return () => {
window.removeEventListener("mousedown", onMouseDown);
};
}, [onClose, visible]);
if (!visible || !src) return null;
return createPortal(
<div className="fullscreen-media-modal__overlay">
<dialog className="fullscreen-media-modal" open aria-label={alt}>
<button
type="button"
onClick={onClose}
className="fullscreen-media-modal__close-button"
aria-label={t("close")}
>
<XIcon size={24} />
</button>
<div
ref={containerRef}
className="fullscreen-media-modal__image-container"
>
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
</div>
</dialog>
</div>,
document.body
);
}

View File

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

View File

@@ -20,3 +20,4 @@ export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions"; export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating"; export * from "./star-rating/star-rating";
export * from "./search-dropdown/search-dropdown"; export * from "./search-dropdown/search-dropdown";
export * from "./fullscreen-media-modal/fullscreen-media-modal";

View File

@@ -10,7 +10,7 @@ import cn from "classnames";
export interface ModalProps { export interface ModalProps {
visible: boolean; visible: boolean;
title: string; title: React.ReactNode;
description?: string; description?: string;
onClose: () => void; onClose: () => void;
large?: boolean; large?: boolean;
@@ -115,7 +115,6 @@ export function Modal({
"modal--large": large, "modal--large": large,
})} })}
role="dialog" role="dialog"
aria-labelledby={title}
aria-describedby={description} aria-describedby={description}
ref={modalContentRef} ref={modalContentRef}
data-hydra-dialog data-hydra-dialog

View File

@@ -5,7 +5,7 @@
background-color: globals.$dark-background-color; background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color; border: 1px solid globals.$border-color;
border-radius: 8px; border-radius: 8px;
max-height: 300px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000; 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 { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames"; import cn from "classnames";
@@ -92,23 +92,8 @@ export function SearchDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose, searchContainerRef]); }, [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; if (!visible) return null;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0; const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0; const hasSuggestions = suggestions.length > 0;
@@ -158,7 +143,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("history", index), activeIndex === getItemIndex("history", index),
})} })}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("history", item)} onClick={() => onSelectHistory(item.query)}
> >
<ClockIcon size={16} className="search-dropdown__item-icon" /> <ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text"> <span className="search-dropdown__item-text">
@@ -200,7 +185,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("suggestion", index), activeIndex === getItemIndex("suggestion", index),
})} })}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("suggestion", item)} onClick={() => onSelectSuggestion(item)}
> >
{item.iconUrl ? ( {item.iconUrl ? (
<img <img
@@ -227,13 +212,6 @@ export function SearchDropdown({
{isLoadingSuggestions && !hasSuggestions && !hasHistory && ( {isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div> <div className="search-dropdown__loading">{t("loading")}</div>
)} )}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div> </div>
); );

View File

@@ -5,6 +5,7 @@ import cn from "classnames";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { GameContextMenu } from ".."; import { GameContextMenu } from "..";
import { useAppSelector } from "@renderer/hooks";
interface SidebarGameItemProps { interface SidebarGameItemProps {
game: LibraryGame; game: LibraryGame;
@@ -18,6 +19,9 @@ export function SidebarGameItem({
getGameTitle, getGameTitle,
}: Readonly<SidebarGameItemProps>) { }: Readonly<SidebarGameItemProps>) {
const location = useLocation(); const location = useLocation();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
visible: boolean; visible: boolean;
position: { x: number; y: number }; position: { x: number; y: number };
@@ -81,11 +85,12 @@ export function SidebarGameItem({
{getGameTitle(game)} {getGameTitle(game)}
</span> </span>
{(game.newDownloadOptionsCount ?? 0) > 0 && ( {userPreferences?.enableNewDownloadOptionsBadges !== false &&
<span className="sidebar__game-badge"> (game.newDownloadOptionsCount ?? 0) > 0 && (
+{game.newDownloadOptionsCount} <span className="sidebar__game-badge">
</span> +{game.newDownloadOptionsCount}
)} </span>
)}
</button> </button>
</li> </li>

View File

@@ -46,7 +46,7 @@
white-space: nowrap; white-space: nowrap;
} }
&__friends-button { &__notification-button {
color: globals.$muted-color; color: globals.$muted-color;
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
@@ -62,7 +62,7 @@
} }
} }
&__friends-button-badge { &__notification-button-badge {
background-color: globals.$success-color; background-color: globals.$success-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -73,6 +73,8 @@
position: absolute; position: absolute;
top: -5px; top: -5px;
right: -5px; right: -5px;
font-size: 10px;
font-weight: bold;
} }
&__game-running-icon { &__game-running-icon {

View File

@@ -1,12 +1,13 @@
import { useNavigate } from "react-router-dom"; 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 { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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 SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar"; import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared"; import { AuthPage } from "@shared";
import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss"; import "./sidebar-profile.scss";
export function SidebarProfile() { export function SidebarProfile() {
@@ -14,11 +15,75 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar"); const { t } = useTranslation("sidebar");
const { userDetails, friendRequestCount, showFriendsModal } = const { userDetails } = useUserDetails();
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning); 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 = () => { const handleProfileClick = () => {
if (userDetails === null) { if (userDetails === null) {
window.electron.openAuthWindow(AuthPage.SignIn); window.electron.openAuthWindow(AuthPage.SignIn);
@@ -28,28 +93,24 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`); navigate(`/profile/${userDetails.id}`);
}; };
const friendsButton = useMemo(() => { const notificationsButton = useMemo(() => {
if (!userDetails) return null;
return ( return (
<button <button
type="button" type="button"
className="sidebar-profile__friends-button" className="sidebar-profile__notification-button"
onClick={() => onClick={() => navigate("/notifications")}
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id) title={t("notifications")}
}
title={t("friends")}
> >
{friendRequestCount > 0 && ( {notificationCount > 0 && (
<small className="sidebar-profile__friends-button-badge"> <small className="sidebar-profile__notification-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount} {notificationCount > 99 ? "99+" : notificationCount}
</small> </small>
)} )}
<PeopleIcon size={16} /> <BellIcon size={16} />
</button> </button>
); );
}, [userDetails, t, friendRequestCount, showFriendsModal]); }, [t, notificationCount, navigate]);
const gameRunningDetails = () => { const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null; if (!userDetails || !gameRunning) return null;
@@ -98,7 +159,7 @@ export function SidebarProfile() {
</div> </div>
</button> </button>
{friendsButton} {notificationsButton}
</div> </div>
); );
} }

View File

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

View File

@@ -14,6 +14,7 @@ import type {
GameStats, GameStats,
UserDetails, UserDetails,
FriendRequestSync, FriendRequestSync,
NotificationSync,
GameArtifact, GameArtifact,
LudusaviBackup, LudusaviBackup,
UserAchievement, UserAchievement,
@@ -31,6 +32,7 @@ import type {
Game, Game,
DiskUsage, DiskUsage,
DownloadSource, DownloadSource,
LocalNotification,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -165,6 +167,10 @@ declare global {
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>; refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
getGameInstallerActionType: (
shop: GameShop,
objectId: string
) => Promise<"install" | "open-folder">;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
openGame: ( openGame: (
@@ -208,6 +214,13 @@ declare global {
onExtractionComplete: ( onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => () => Electron.IpcRenderer;
onArchiveDeletionPrompt: (
cb: (archivePaths: string[]) => void
) => () => Electron.IpcRenderer;
deleteArchive: (filePath: string) => Promise<boolean>;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>; getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>; createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
@@ -380,10 +393,12 @@ declare global {
processProfileImage: ( processProfileImage: (
path: string path: string
) => Promise<{ imagePath: string; mimeType: string }>; ) => Promise<{ imagePath: string; mimeType: string }>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: ( onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onSyncNotificationCount: (
cb: (notification: NotificationSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: ( updateFriendRequest: (
userId: string, userId: string,
action: FriendRequestAction action: FriendRequestAction
@@ -391,6 +406,15 @@ declare global {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>; 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: ( onAchievementUnlocked: (
cb: ( cb: (
position?: AchievementCustomNotificationPosition, position?: AchievementCustomNotificationPosition,

View File

@@ -1,17 +1,28 @@
import { createSlice } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit";
import type { DownloadProgress } from "@types"; import type { DownloadProgress, GameShop } from "@types";
export interface ExtractionInfo {
visibleId: string;
progress: number;
}
export interface DownloadState { export interface DownloadState {
lastPacket: DownloadProgress | null; lastPacket: DownloadProgress | null;
gameId: string | null; gameId: string | null;
gamesWithDeletionInProgress: string[]; gamesWithDeletionInProgress: string[];
extraction: ExtractionInfo | null;
peakSpeeds: Record<string, number>;
speedHistory: Record<string, number[]>;
} }
const initialState: DownloadState = { const initialState: DownloadState = {
lastPacket: null, lastPacket: null,
gameId: null, gameId: null,
gamesWithDeletionInProgress: [], gamesWithDeletionInProgress: [],
extraction: null,
peakSpeeds: {},
speedHistory: {},
}; };
export const downloadSlice = createSlice({ export const downloadSlice = createSlice({
@@ -20,7 +31,33 @@ export const downloadSlice = createSlice({
reducers: { reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => { setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload; state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Ensure payload exists and has a valid gameId before accessing
const payload = action.payload;
if (!state.gameId && payload?.gameId) {
state.gameId = payload.gameId;
}
// Track peak speed and speed history atomically when packet arrives
if (payload?.gameId && payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = payload;
// Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0;
if (downloadSpeed > currentPeak) {
state.peakSpeeds[gameId] = downloadSpeed;
}
// Update speed history for chart
if (!state.speedHistory[gameId]) {
state.speedHistory[gameId] = [];
}
state.speedHistory[gameId].push(downloadSpeed);
// Keep only last 120 entries
if (state.speedHistory[gameId].length > 120) {
state.speedHistory[gameId].shift();
}
}
}, },
clearDownload: (state) => { clearDownload: (state) => {
state.lastPacket = null; state.lastPacket = null;
@@ -38,6 +75,37 @@ export const downloadSlice = createSlice({
const index = state.gamesWithDeletionInProgress.indexOf(action.payload); const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1); if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
}, },
setExtractionProgress: (
state,
action: PayloadAction<{
shop: GameShop;
objectId: string;
progress: number;
}>
) => {
const { shop, objectId, progress } = action.payload;
state.extraction = {
visibleId: `${shop}:${objectId}`,
progress,
};
},
clearExtraction: (state) => {
state.extraction = null;
},
updatePeakSpeed: (
state,
action: PayloadAction<{ gameId: string; speed: number }>
) => {
const { gameId, speed } = action.payload;
const currentPeak = state.peakSpeeds[gameId] || 0;
if (speed > currentPeak) {
state.peakSpeeds[gameId] = speed;
}
},
clearPeakSpeed: (state, action: PayloadAction<string>) => {
state.peakSpeeds[action.payload] = 0;
state.speedHistory[action.payload] = [];
},
}, },
}); });
@@ -46,4 +114,8 @@ export const {
clearDownload, clearDownload,
setGameDeleting, setGameDeleting,
removeGameFromDeleting, removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
updatePeakSpeed,
clearPeakSpeed,
} = downloadSlice.actions; } = downloadSlice.actions;

View File

@@ -1,5 +1,4 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types"; import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState { export interface UserDetailsState {
@@ -7,9 +6,6 @@ export interface UserDetailsState {
profileBackground: null | string; profileBackground: null | string;
friendRequests: FriendRequest[]; friendRequests: FriendRequest[];
friendRequestCount: number; friendRequestCount: number;
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
profileBackground: null, profileBackground: null,
friendRequests: [], friendRequests: [],
friendRequestCount: 0, friendRequestCount: 0,
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
setFriendRequestCount: (state, action: PayloadAction<number>) => { setFriendRequestCount: (state, action: PayloadAction<number>) => {
state.friendRequestCount = action.payload; 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, setProfileBackground,
setFriendRequests, setFriendRequests,
setFriendRequestCount, setFriendRequestCount,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions; } = userDetailsSlice.actions;

View File

@@ -4,8 +4,6 @@ import {
setProfileBackground, setProfileBackground,
setUserDetails, setUserDetails,
setFriendRequests, setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features"; } from "@renderer/features";
import type { import type {
FriendRequestAction, FriendRequestAction,
@@ -13,20 +11,12 @@ import type {
UserDetails, UserDetails,
FriendRequest, FriendRequest,
} from "@types"; } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { const { userDetails, profileBackground, friendRequests, friendRequestCount } =
userDetails, useAppSelector((state) => state.userDetails);
profileBackground,
friendRequests,
friendRequestCount,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
@@ -69,6 +59,7 @@ export function useUserDetails() {
username: userDetails?.username || "", username: userDetails?.username || "",
subscription: userDetails?.subscription || null, subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "", featurebaseJwt: userDetails?.featurebaseJwt || "",
workwondersJwt: userDetails?.workwondersJwt || "",
karma: userDetails?.karma || 0, karma: userDetails?.karma || 0,
}); });
}, },
@@ -85,24 +76,11 @@ export function useUserDetails() {
return window.electron.hydraApi return window.electron.hydraApi
.get<FriendRequest[]>("/profile/friend-requests") .get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => { .then((friendRequests) => {
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests)); dispatch(setFriendRequests(friendRequests));
}) })
.catch(() => {}); .catch(() => {});
}, [dispatch]); }, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests();
},
[dispatch, fetchFriendRequests]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback( const sendFriendRequest = useCallback(
async (userId: string) => { async (userId: string) => {
return window.electron.hydraApi return window.electron.hydraApi
@@ -134,7 +112,7 @@ export function useUserDetails() {
); );
const undoFriendship = (userId: string) => const undoFriendship = (userId: string) =>
window.electron.hydraApi.delete(`/profile/friends/${userId}`); window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
const blockUser = (userId: string) => const blockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/block`); window.electron.hydraApi.post(`/users/${userId}/block`);
@@ -152,12 +130,7 @@ export function useUserDetails() {
profileBackground, profileBackground,
friendRequests, friendRequests,
friendRequestCount, friendRequestCount,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
hasActiveSubscription, hasActiveSubscription,
showFriendsModal,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
signOut, signOut,
clearUserDetails, clearUserDetails,

View File

@@ -31,6 +31,7 @@ import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements"; import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor"; import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library"; import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log; console.log = logger.log;
@@ -76,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} /> <Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} /> <Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route> </Route>
<Route path="/theme-editor" element={<ThemeEditor />} /> <Route path="/theme-editor" element={<ThemeEditor />} />

View File

@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { ConfirmationModal } from "@renderer/components";
interface ArchiveDeletionModalProps {
visible: boolean;
archivePaths: string[];
onClose: () => void;
}
export function ArchiveDeletionModal({
visible,
archivePaths,
onClose,
}: Readonly<ArchiveDeletionModalProps>) {
const { t } = useTranslation("downloads");
const fullFileName =
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
const maxLength = 40;
const fileName =
fullFileName.length > maxLength
? `${fullFileName.slice(0, maxLength)}`
: fullFileName;
const handleConfirm = async () => {
for (const archivePath of archivePaths) {
await window.electron.deleteArchive(archivePath);
}
onClose();
};
return (
<ConfirmationModal
visible={visible}
title={t("delete_archive_title", { fileName })}
descriptionText={t("delete_archive_description")}
confirmButtonLabel={t("yes")}
cancelButtonLabel={t("no")}
onConfirm={handleConfirm}
onClose={onClose}
/>
);
}

View File

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

View File

@@ -1,6 +1,6 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components"; import { Badge, Button, ConfirmationModal } from "@renderer/components";
import { import {
formatDownloadProgress, formatDownloadProgress,
buildGameDetailsPath, buildGameDetailsPath,
@@ -32,12 +32,12 @@ import {
FileDirectoryIcon, FileDirectoryIcon,
LinkIcon, LinkIcon,
PlayIcon, PlayIcon,
ThreeBarsIcon,
TrashIcon, TrashIcon,
UnlinkIcon, UnlinkIcon,
XCircleIcon, XCircleIcon,
GraphIcon, GraphIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js"; import { average } from "color.js";
interface AnimatedPercentageProps { interface AnimatedPercentageProps {
@@ -128,16 +128,20 @@ function SpeedChart({
g = 255, g = 255,
b = 255; b = 255;
if (color.startsWith("#")) { if (color.startsWith("#")) {
const hex = color.replace("#", ""); let hex = color.replace("#", "");
r = Number.parseInt(hex.substring(0, 2), 16); // Handle shorthand hex colors (e.g., "#fff" -> "#ffffff")
g = Number.parseInt(hex.substring(2, 4), 16); if (hex.length === 3) {
b = Number.parseInt(hex.substring(4, 6), 16); hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
r = Number.parseInt(hex.substring(0, 2), 16) || 255;
g = Number.parseInt(hex.substring(2, 4), 16) || 255;
b = Number.parseInt(hex.substring(4, 6), 16) || 255;
} else if (color.startsWith("rgb")) { } else if (color.startsWith("rgb")) {
const matches = color.match(/\d+/g); const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) { if (matches && matches.length >= 3) {
r = Number.parseInt(matches[0]); r = Number.parseInt(matches[0]) || 255;
g = Number.parseInt(matches[1]); g = Number.parseInt(matches[1]) || 255;
b = Number.parseInt(matches[2]); b = Number.parseInt(matches[2]) || 255;
} }
} }
const displaySpeeds = speeds.slice(-totalBars); const displaySpeeds = speeds.slice(-totalBars);
@@ -203,6 +207,7 @@ function SpeedChart({
interface HeroDownloadViewProps { interface HeroDownloadViewProps {
game: LibraryGame; game: LibraryGame;
isGameDownloading: boolean; isGameDownloading: boolean;
isGameExtracting?: boolean;
downloadSpeed: number; downloadSpeed: number;
finalDownloadSize: string; finalDownloadSize: string;
peakSpeed: number; peakSpeed: number;
@@ -214,13 +219,14 @@ interface HeroDownloadViewProps {
calculateETA: () => string; calculateETA: () => string;
pauseDownload: (shop: GameShop, objectId: string) => void; pauseDownload: (shop: GameShop, objectId: string) => void;
resumeDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void;
cancelDownload: (shop: GameShop, objectId: string) => void; onCancelClick: (shop: GameShop, objectId: string) => void;
t: (key: string) => string; t: (key: string) => string;
} }
function HeroDownloadView({ function HeroDownloadView({
game, game,
isGameDownloading, isGameDownloading,
isGameExtracting = false,
downloadSpeed, downloadSpeed,
finalDownloadSize, finalDownloadSize,
peakSpeed, peakSpeed,
@@ -232,7 +238,7 @@ function HeroDownloadView({
calculateETA, calculateETA,
pauseDownload, pauseDownload,
resumeDownload, resumeDownload,
cancelDownload, onCancelClick,
t, t,
}: Readonly<HeroDownloadViewProps>) { }: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -278,11 +284,17 @@ function HeroDownloadView({
<div className="download-group__progress-row download-group__progress-row--bar"> <div className="download-group__progress-row download-group__progress-row--bar">
<div className="download-group__progress-wrapper"> <div className="download-group__progress-wrapper">
<div className="download-group__progress-info-row"> <div className="download-group__progress-info-row">
{lastPacket?.isCheckingFiles ? ( {isGameExtracting && (
<span className="download-group__progress-status">
{t("extracting")}
</span>
)}
{!isGameExtracting && lastPacket?.isCheckingFiles && (
<span className="download-group__progress-status"> <span className="download-group__progress-status">
{t("checking_files")} {t("checking_files")}
</span> </span>
) : ( )}
{!isGameExtracting && !lastPacket?.isCheckingFiles && (
<span className="download-group__progress-size"> <span className="download-group__progress-size">
<DownloadIcon size={14} /> <DownloadIcon size={14} />
{isGameDownloading && lastPacket {isGameDownloading && lastPacket
@@ -293,7 +305,7 @@ function HeroDownloadView({
<span></span> <span></span>
</div> </div>
<div className="download-group__progress-info-row"> <div className="download-group__progress-info-row">
{!lastPacket?.isCheckingFiles && ( {!lastPacket?.isCheckingFiles && !isGameExtracting && (
<span className="download-group__progress-time"> <span className="download-group__progress-time">
{isGameDownloading && {isGameDownloading &&
lastPacket?.timeRemaining && lastPacket?.timeRemaining &&
@@ -311,42 +323,44 @@ function HeroDownloadView({
</div> </div>
<div className="download-group__progress-bar"> <div className="download-group__progress-bar">
<div <div
className="download-group__progress-fill" className={`download-group__progress-fill ${isGameExtracting ? "download-group__progress-fill--extraction" : ""}`}
style={{ style={{
width: `${currentProgress * 100}%`, width: `${currentProgress * 100}%`,
}} }}
/> />
</div> </div>
</div> </div>
<div className="download-group__hero-buttons"> {!isGameExtracting && (
{isGameDownloading ? ( <div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button <button
type="button" type="button"
onClick={() => pauseDownload(game.shop, game.objectId)} onClick={() => onCancelClick(game.shop, game.objectId)}
className="download-group__glass-btn" className="download-group__glass-btn"
> >
<ColumnsIcon size={14} /> <XCircleIcon size={14} />
{t("pause")} {t("cancel")}
</button> </button>
) : ( </div>
<button )}
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
</button>
</div>
</div> </div>
</div> </div>
@@ -398,10 +412,12 @@ function HeroDownloadView({
</div> </div>
)} )}
{game.download?.downloader && ( {game.download?.downloader !== undefined && (
<div className="download-group__stat-item"> <div className="download-group__stat-item">
<div className="download-group__stat-content"> <div className="download-group__stat-content">
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge> <Badge>
{DOWNLOADER_NAME[Number(game.download.downloader)]}
</Badge>
</div> </div>
</div> </div>
)} )}
@@ -436,12 +452,15 @@ export function DownloadGroup({
seedingStatus, seedingStatus,
}: Readonly<DownloadGroupProps>) { }: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
const navigate = useNavigate(); const navigate = useNavigate();
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const extraction = useAppSelector((state) => state.download.extraction);
const { updateLibrary } = useLibrary(); const { updateLibrary } = useLibrary();
const { const {
@@ -496,14 +515,25 @@ export function DownloadGroup({
const { formatDistance } = useDate(); const { formatDistance } = useDate();
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({}); // Get speed history and peak speeds from Redux (centralized state)
const speedHistoryRef = useRef<Record<string, number[]>>({}); const speedHistory = useAppSelector((state) => state.download.speedHistory);
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
const [dominantColors, setDominantColors] = useState<Record<string, string>>( const [dominantColors, setDominantColors] = useState<Record<string, string>>(
{} {}
); );
const [optimisticallyResumed, setOptimisticallyResumed] = useState< const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
null
);
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
string | null
>(null);
const [gameActionTypes, setGameActionTypes] = useState<
Record<string, "install" | "open-folder">
>({});
const extractDominantColor = useCallback( const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => { async (imageUrl: string, gameId: string) => {
@@ -560,68 +590,8 @@ export function DownloadGroup({
}); });
}, [library, lastPacket?.gameId]); }, [library, lastPacket?.gameId]);
useEffect(() => { // Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) { // No local effect needed - data is updated atomically when packets arrive
const gameId = lastPacket.gameId;
const currentPeak = peakSpeeds[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
setPeakSpeeds((prev) => ({
...prev,
[gameId]: lastPacket.downloadSpeed,
}));
}
if (!speedHistoryRef.current[gameId]) {
speedHistoryRef.current[gameId] = [];
}
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
if (speedHistoryRef.current[gameId].length > 120) {
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
useEffect(() => {
for (const game of library) {
if (
game.download &&
game.download.progress < 0.01 &&
game.download.status !== "paused"
) {
// Fresh download - clear any old data
if (speedHistoryRef.current[game.id]?.length > 0) {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}
}
}
}, [library]);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
for (const game of library) {
if (
game.download?.progress === 1 &&
speedHistoryRef.current[game.id]?.length > 0
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}, 10_000);
timeouts.push(timeout);
}
}
return () => {
for (const timeout of timeouts) {
clearTimeout(timeout);
}
};
}, [library]);
useEffect(() => { useEffect(() => {
if (library.length > 0 && title === t("download_in_progress")) { if (library.length > 0 && title === t("download_in_progress")) {
@@ -654,11 +624,18 @@ export function DownloadGroup({
const download = game.download!; const download = game.download!;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
if (download.fileSize != null) return formatBytes(download.fileSize); // Check lastPacket first for most up-to-date size during active downloads
if (
if (lastPacket?.download.fileSize && isGameDownloading) isGameDownloading &&
lastPacket?.download.fileSize &&
lastPacket.download.fileSize > 0
)
return formatBytes(lastPacket.download.fileSize); return formatBytes(lastPacket.download.fileSize);
// Then check the stored download size (must be > 0 to be valid)
if (download.fileSize != null && download.fileSize > 0)
return formatBytes(download.fileSize);
return "N/A"; return "N/A";
}; };
@@ -692,6 +669,27 @@ export function DownloadGroup({
[updateLibrary] [updateLibrary]
); );
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
setGameToCancelShop(shop);
setGameToCancelObjectId(objectId);
setCancelModalVisible(true);
}, []);
const handleConfirmCancel = useCallback(async () => {
if (gameToCancelShop && gameToCancelObjectId) {
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
}
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
const handleCancelModalClose = useCallback(() => {
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, []);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download; const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
@@ -700,14 +698,6 @@ export function DownloadGroup({
if (game.download?.progress === 1) { if (game.download?.progress === 1) {
const actions = [ const actions = [
{
label: t("install"),
disabled: deleting,
onClick: () => {
openGameInstaller(game.shop, game.objectId);
},
icon: <DownloadIcon />,
},
{ {
label: t("extract"), label: t("extract"),
disabled: game.download.extracting, disabled: game.download.extracting,
@@ -762,7 +752,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -787,7 +777,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -811,6 +801,37 @@ export function DownloadGroup({
] ]
); );
// Fetch action types for completed games
useEffect(() => {
const fetchActionTypes = async () => {
const completedGames = library.filter(
(game) => game.download?.progress === 1
);
const actionTypesPromises = completedGames.map(async (game) => {
try {
const actionType = await window.electron.getGameInstallerActionType(
game.shop,
game.objectId
);
return { gameId: game.id, actionType };
} catch {
return { gameId: game.id, actionType: "open-folder" as const };
}
});
const results = await Promise.all(actionTypesPromises);
const newActionTypes: Record<string, "install" | "open-folder"> = {};
results.forEach(({ gameId, actionType }) => {
newActionTypes[gameId] = actionType;
});
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
};
fetchActionTypes();
}, [library]);
if (!library.length) return null; if (!library.length) return null;
const isDownloadingGroup = title === t("download_in_progress"); const isDownloadingGroup = title === t("download_in_progress");
@@ -819,146 +840,205 @@ export function DownloadGroup({
if (isDownloadingGroup && library.length > 0) { if (isDownloadingGroup && library.length > 0) {
const game = library[0]; const game = library[0];
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameExtracting = extraction?.visibleId === game.id;
const isGameDownloading =
isGameDownloadingMap[game.id] && !isGameExtracting;
const downloadSpeed = isGameDownloading const downloadSpeed = isGameDownloading
? (lastPacket?.downloadSpeed ?? 0) ? (lastPacket?.downloadSpeed ?? 0)
: 0; : 0;
const finalDownloadSize = getFinalDownloadSize(game); const finalDownloadSize = getFinalDownloadSize(game);
const peakSpeed = peakSpeeds[game.id] || 0; // Use lastPacket.gameId for lookup since that's the key used to store the data
const currentProgress = // Fall back to game.id if lastPacket is not available
isGameDownloading && lastPacket const dataKey = lastPacket?.gameId ?? game.id;
? lastPacket.progress const gameSpeedHistory = speedHistory[dataKey] ?? [];
: game.download?.progress || 0; const storedPeak = peakSpeeds[dataKey];
// Use stored peak if available and > 0, otherwise use current speed as initial value
const peakSpeed =
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
let currentProgress = game.download?.progress || 0;
if (isGameExtracting) {
currentProgress = extraction.progress;
} else if (isGameDownloading && lastPacket) {
currentProgress = lastPacket.progress;
}
const dominantColor = dominantColors[game.id] || "#fff"; const dominantColor = dominantColors[game.id] || "#fff";
return ( return (
<HeroDownloadView <>
game={game} <ConfirmationModal
isGameDownloading={isGameDownloading} visible={cancelModalVisible}
downloadSpeed={downloadSpeed} title={t("cancel_download")}
finalDownloadSize={finalDownloadSize} descriptionText={t("cancel_download_description")}
peakSpeed={peakSpeed} confirmButtonLabel={t("yes_cancel")}
currentProgress={currentProgress} cancelButtonLabel={t("keep_downloading")}
dominantColor={dominantColor} onConfirm={handleConfirmCancel}
lastPacket={lastPacket} onClose={handleCancelModalClose}
speedHistory={speedHistoryRef.current[game.id] || []} />
formatSpeed={formatSpeed} <HeroDownloadView
calculateETA={calculateETA} game={game}
pauseDownload={pauseDownload} isGameDownloading={isGameDownloading}
resumeDownload={resumeDownload} isGameExtracting={isGameExtracting}
cancelDownload={cancelDownload} downloadSpeed={downloadSpeed}
t={t} 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 ( return (
<div <>
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`} <ConfirmationModal
> visible={cancelModalVisible}
<div className="download-group__header"> title={t("cancel_download")}
<div className="download-group__header-title-group"> descriptionText={t("cancel_download_description")}
<h2>{title}</h2> confirmButtonLabel={t("yes_cancel")}
<h3 className="download-group__header-count">{library.length}</h3> cancelButtonLabel={t("keep_downloading")}
onConfirm={handleConfirmCancel}
onClose={handleCancelModalClose}
/>
<div
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
>
<div className="download-group__header">
<div className="download-group__header-title-group">
<h2>{title}</h2>
<h3 className="download-group__header-count">{library.length}</h3>
</div>
</div> </div>
</div>
<ul className="download-group__simple-list"> <ul className="download-group__simple-list">
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return ( return (
<li key={game.id} className="download-group__simple-card"> <li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
<div className="download-group__simple-info">
<button <button
type="button" type="button"
onClick={() => navigate(buildGameDetailsPath(game))} onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button" className="download-group__simple-thumbnail"
> >
<h3 className="download-group__simple-title">{game.title}</h3> <img src={game.libraryImageUrl || ""} alt={game.title} />
</button> </button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row"> <div className="download-group__simple-info">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge> <button
</div> type="button"
<div className="download-group__simple-meta-row"> onClick={() => navigate(buildGameDetailsPath(game))}
{game.download?.extracting ? ( className="download-group__simple-title-button"
<span className="download-group__simple-extracting"> >
{t("extracting")} <h3 className="download-group__simple-title">
</span> {game.title}
) : ( </h3>
<span className="download-group__simple-size"> </button>
<DownloadIcon size={14} /> <div className="download-group__simple-meta">
{size} <div className="download-group__simple-meta-row">
</span> <Badge>
)} {DOWNLOADER_NAME[Number(game.download!.downloader)]}
{game.download?.progress === 1 && seeding && ( </Badge>
<span className="download-group__simple-seeding"> </div>
{t("seeding")} <div className="download-group__simple-meta-row">
</span> {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> </div>
</div>
{isQueuedGroup && ( {isQueuedGroup && (
<div className="download-group__simple-progress"> <div className="download-group__simple-progress">
<span className="download-group__simple-progress-text"> <span className="download-group__simple-progress-text">
{formatDownloadProgress(progress)} {formatDownloadProgress(progress)}
</span> </span>
<div className="download-group__progress-bar download-group__progress-bar--small"> <div className="download-group__progress-bar download-group__progress-bar--small">
<div <div
className="download-group__progress-fill" className="download-group__progress-fill"
style={{ style={{
width: `${progress * 100}%`, width: `${progress * 100}%`,
backgroundColor: "#fff", backgroundColor: "#fff",
}} }}
/> />
</div>
</div> </div>
</div> )}
)}
<div className="download-group__simple-actions"> <div className="download-group__simple-actions">
{game.download?.progress === 1 && ( {game.download?.progress === 1 &&
<Button (() => {
theme="primary" const actionType =
onClick={() => openGameInstaller(game.shop, game.objectId)} gameActionTypes[game.id] || "open-folder";
disabled={isGameDeleting(game.id)} const isInstall = actionType === "install";
className="download-group__simple-menu-btn"
> return (
<PlayIcon size={16} /> <Button
</Button> theme="primary"
)} onClick={() =>
{isQueuedGroup && game.download?.progress !== 1 && ( openGameInstaller(game.shop, game.objectId)
<Button }
theme="primary" disabled={isGameDeleting(game.id)}
onClick={() => resumeDownload(game.shop, game.objectId)} className="download-group__simple-action-btn"
className="download-group__simple-menu-btn" >
tooltip={t("resume")} {isInstall ? (
> <>
<DownloadIcon size={16} /> <DownloadIcon size={16} />
</Button> {t("install")}
)} </>
<DropdownMenu align="end" items={getGameActions(game)}> ) : (
<Button <>
theme="outline" <Folder size={16} />
className="download-group__simple-menu-btn" {tGameDetails("open_folder")}
> </>
<ThreeBarsIcon /> )}
</Button> </Button>
</DropdownMenu> );
</div> })()}
</li> {isQueuedGroup && game.download?.progress !== 1 && (
); <Button
})} theme="primary"
</ul> onClick={() => resumeDownload(game.shop, game.objectId)}
</div> className="download-group__simple-menu-btn"
tooltip={t("resume")}
>
<DownloadIcon size={16} />
</Button>
)}
<DropdownMenu align="end" items={getGameActions(game)}>
<Button
theme="outline"
className="download-group__simple-menu-btn"
>
<MoreVertical size={16} />
</Button>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</div>
</>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDownload, useLibrary } from "@renderer/hooks"; import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
@@ -13,6 +13,7 @@ import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() { export default function Downloads() {
const { library, updateLibrary } = useLibrary(); const { library, updateLibrary } = useLibrary();
const extraction = useAppSelector((state) => state.download.extraction);
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
@@ -39,11 +40,13 @@ export default function Downloads() {
useEffect(() => { useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value)); window.electron.onSeedingStatus((value) => setSeedingStatus(value));
const unsubscribe = window.electron.onExtractionComplete(() => { const unsubscribeExtraction = window.electron.onExtractionComplete(() => {
updateLibrary(); updateLibrary();
}); });
return () => unsubscribe(); return () => {
unsubscribeExtraction();
};
}, [updateLibrary]); }, [updateLibrary]);
const handleOpenGameInstaller = (shop: GameShop, objectId: string) => const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
@@ -72,8 +75,10 @@ export default function Downloads() {
/* Game has been manually added to the library */ /* Game has been manually added to the library */
if (!next.download) return prev; if (!next.download) return prev;
/* Is downloading */ /* Is downloading or extracting */
if (lastPacket?.gameId === next.id || next.download.extracting) const isExtracting =
next.download.extracting || extraction?.visibleId === next.id;
if (lastPacket?.gameId === next.id || isExtracting)
return { ...prev, downloading: [...prev.downloading, next] }; return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */ /* Is either queued or paused */
@@ -96,7 +101,7 @@ export default function Downloads() {
queued, queued,
complete, complete,
}; };
}, [library, lastPacket?.gameId]); }, [library, lastPacket?.gameId, extraction?.visibleId]);
const downloadGroups = [ const downloadGroups = [
{ {

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 { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { HeroPanel } from "./hero"; import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header"; import { DescriptionHeader } from "./description-header/description-header";
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
export function GameDetailsContent() { export function GameDetailsContent() {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const [searchParams] = useSearchParams();
const reviewsRef = useRef<HTMLDivElement>(null);
const { const {
objectId, objectId,
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
getGameArtifacts(); getGameArtifacts();
}, [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 isCustomGame = game?.shop === "custom";
const heroImage = isCustomGame const heroImage = isCustomGame
@@ -229,15 +242,17 @@ export function GameDetailsContent() {
)} )}
{shop !== "custom" && shop && objectId && ( {shop !== "custom" && shop && objectId && (
<GameReviews <div ref={reviewsRef}>
shop={shop} <GameReviews
objectId={objectId} shop={shop}
game={game} objectId={objectId}
userDetailsId={userDetails?.id} game={game}
isGameInLibrary={isGameInLibrary} userDetailsId={userDetails?.id}
hasUserReviewed={hasUserReviewed} isGameInLibrary={isGameInLibrary}
onUserReviewedChange={setHasUserReviewed} hasUserReviewed={hasUserReviewed}
/> onUserReviewedChange={setHasUserReviewed}
/>
</div>
)} )}
</div> </div>

View File

@@ -1,7 +1,12 @@
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload, useFormat } from "@renderer/hooks"; import {
useAppSelector,
useDate,
useDownload,
useFormat,
} from "@renderer/hooks";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
@@ -17,6 +22,9 @@ export function HeroPanelPlaytime() {
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload(); const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate(); const { formatDistance } = useDate();
const extraction = useAppSelector((state) => state.download.extraction);
const isExtracting = extraction?.visibleId === game?.id;
useEffect(() => { useEffect(() => {
if (game?.lastTimePlayed) { if (game?.lastTimePlayed) {
@@ -52,6 +60,16 @@ export function HeroPanelPlaytime() {
const isGameDownloading = const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id; game.download?.status === "active" && lastPacket?.gameId === game.id;
const extractionInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
{t("extracting")}
</Link>
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
</div>
);
const downloadInProgressInfo = ( const downloadInProgressInfo = (
<div className="hero-panel-playtime__download-details"> <div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link"> <Link to="/downloads" className="hero-panel-playtime__downloads-link">
@@ -72,7 +90,8 @@ export function HeroPanelPlaytime() {
return ( return (
<> <>
<p>{t("not_played_yet", { title: game?.title })}</p> <p>{t("not_played_yet", { title: game?.title })}</p>
{hasDownload && downloadInProgressInfo} {isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
</> </>
); );
} }
@@ -81,7 +100,8 @@ export function HeroPanelPlaytime() {
return ( return (
<> <>
<p>{t("playing_now")}</p> <p>{t("playing_now")}</p>
{hasDownload && downloadInProgressInfo} {isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
</> </>
); );
} }
@@ -113,9 +133,9 @@ export function HeroPanelPlaytime() {
})} })}
</p> </p>
{hasDownload ? ( {isExtracting && extractionInProgressInfo}
downloadInProgressInfo {!isExtracting && hasDownload && downloadInProgressInfo}
) : ( {!isExtracting && !hasDownload && (
<p> <p>
{t("last_time_played", { {t("last_time_played", {
period: lastTimePlayed, period: lastTimePlayed,

View File

@@ -80,5 +80,11 @@
&--disabled { &--disabled {
opacity: globals.$disabled-opacity; opacity: globals.$disabled-opacity;
} }
&--extraction {
&::-webkit-progress-value {
background-color: #fff;
}
}
} }
} }

View File

@@ -1,7 +1,7 @@
import { useContext } from "react"; import { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDate, useDownload } from "@renderer/hooks"; import { useAppSelector, useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelActions } from "./hero-panel-actions";
import { HeroPanelPlaytime } from "./hero-panel-playtime"; import { HeroPanelPlaytime } from "./hero-panel-playtime";
@@ -18,9 +18,13 @@ export function HeroPanel() {
const { lastPacket } = useDownload(); const { lastPacket } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const isGameDownloading = const isGameDownloading =
game?.download?.status === "active" && lastPacket?.gameId === game?.id; game?.download?.status === "active" && lastPacket?.gameId === game?.id;
const isExtracting = extraction?.visibleId === game?.id;
const getInfo = () => { const getInfo = () => {
if (!game) { if (!game) {
const [latestRepack] = repacks; const [latestRepack] = repacks;
@@ -49,6 +53,8 @@ export function HeroPanel() {
(game?.download?.status === "active" && game?.download?.progress < 1) || (game?.download?.status === "active" && game?.download?.progress < 1) ||
game?.download?.status === "paused"; game?.download?.status === "paused";
const showExtractionProgressBar = isExtracting;
return ( return (
<div className="hero-panel__container"> <div className="hero-panel__container">
<div className="hero-panel"> <div className="hero-panel">
@@ -72,6 +78,14 @@ export function HeroPanel() {
}`} }`}
/> />
)} )}
{showExtractionProgressBar && (
<progress
max={1}
value={extraction?.progress ?? 0}
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -19,23 +19,173 @@
color: globals.$body-color; color: globals.$body-color;
} }
&__downloaders { &__downloaders-list-wrapper {
display: grid; border: 1px solid globals.$border-color;
gap: globals.$spacing-unit; overflow: hidden;
grid-template-columns: repeat(2, 1fr); background-color: globals.$dark-background-color;
} }
&__downloader-option { &__downloaders-list {
position: relative; display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&:only-child { &::-webkit-scrollbar {
grid-column: 1 / -1; 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 { &__downloader-item {
position: absolute; display: flex;
left: calc(globals.$spacing-unit * 2); 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 { &__path-error {
@@ -49,4 +199,17 @@
&__change-path-button { &__change-path-button {
align-self: flex-end; 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 { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { import {
Badge,
Button, Button,
CheckboxField, CheckboxField,
Link, Link,
Modal, Modal,
TextField, TextField,
} from "@renderer/components"; } from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import {
import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; 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"; import "./download-settings-modal.scss";
export interface DownloadSettingsModalProps { export interface DownloadSettingsModalProps {
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>( const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null null
); );
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature(); const { isFeatureEnabled, Feature } = useFeature();
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
} }
}, [visible, checkFolderWritePermission, selectedPath]); }, [visible, checkFolderWritePermission, selectedPath]);
const downloaders = useMemo(() => { const downloadOptions = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []); const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
}, [repack?.uris]);
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( const getDefaultDownloader = useCallback(
(availableDownloaders: Downloader[]) => { (availableDownloaders: Downloader[]) => {
if (availableDownloaders.length === 0) return null; if (availableDownloaders.length === 0) return null;
if (availableDownloaders.includes(Downloader.Hydra)) {
return Downloader.Hydra;
}
if (availableDownloaders.includes(Downloader.RealDebrid)) { if (availableDownloaders.includes(Downloader.RealDebrid)) {
return Downloader.RealDebrid; return Downloader.RealDebrid;
} }
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath)); .then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
} }
const filteredDownloaders = downloaders.filter((downloader) => { const availableDownloaders = downloadOptions
if (downloader === Downloader.RealDebrid) .filter((option) => option.isAvailable)
return userPreferences?.realDebridApiToken; .map((option) => option.downloader);
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
});
setSelectedDownloader(getDefaultDownloader(filteredDownloaders)); setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [ }, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
const handleChooseDownloadsPath = async () => { const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
@@ -186,33 +252,144 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field"> <div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span> <span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders"> <div className="download-settings-modal__downloaders-list-wrapper">
{downloaders.map((downloader) => { <div className="download-settings-modal__downloaders-list">
const shouldDisableButton = {downloadOptions.map((option, index) => {
(downloader === Downloader.RealDebrid && const isSelected = selectedDownloader === option.downloader;
!userPreferences?.realDebridApiToken) || const tooltipId = `availability-indicator-${option.downloader}`;
(downloader === Downloader.TorBox && const isLastItem = index === downloadOptions.length - 1;
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&
!isFeatureEnabled(Feature.Nimbus));
return ( const Indicator = option.isAvailable ? motion.span : "span";
<Button
key={downloader} const isDisabled =
className="download-settings-modal__downloader-option" !option.canHandle ||
theme={ (!option.isAvailable && !option.isAvailableButNotConfigured);
selectedDownloader === downloader ? "primary" : "outline"
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)} if (option.isAvailableButNotConfigured) {
> return (
{selectedDownloader === downloader && ( <span
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" /> className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
)} data-tooltip-id={tooltipId}
{DOWNLOADER_NAME[downloader]} data-tooltip-content={t("downloader_not_configured")}
</Button> />
); );
})} }
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>
</div> </div>
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
disabled={ disabled={
downloadStarting || downloadStarting ||
selectedDownloader === null || selectedDownloader === null ||
!hasWritePermission !hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
} }
> >
<DownloadIcon /> {downloadStarting ? (
{t("download_now")} <>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
</Button> </Button>
</div> </div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal> </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 { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { orderBy } from "lodash-es"; 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 { clearNewDownloadOptions } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service"; import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers"; import { getGameKey } from "@renderer/helpers";
@@ -70,6 +75,9 @@ export function RepacksModal({
const { formatDate } = useDate(); const { formatDate } = useDate();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const getHashFromMagnet = (magnet: string) => { const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") { if (!magnet || typeof magnet !== "string") {
@@ -129,10 +137,12 @@ export function RepacksModal({
} }
}; };
if (visible) { if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) {
fetchLastCheckTimestamp(); fetchLastCheckTimestamp();
} else {
setIsLoadingTimestamp(false);
} }
}, [visible, repacks]); }, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -363,11 +373,13 @@ export function RepacksModal({
> >
<p className="repacks-modal__repack-title"> <p className="repacks-modal__repack-title">
{repack.title} {repack.title}
{isNewRepack(repack) && ( {userPreferences?.enableNewDownloadOptionsBadges !==
<span className="repacks-modal__new-badge"> false &&
{t("new_download_option")} isNewRepack(repack) && (
</span> <span className="repacks-modal__new-badge">
)} {t("new_download_option")}
</span>
)}
</p> </p>
{isLastDownloadedOption && ( {isLastDownloadedOption && (

View File

@@ -221,6 +221,26 @@
left: 0; left: 0;
z-index: 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 { @keyframes pulse {

View File

@@ -1,7 +1,12 @@
import { LibraryGame } from "@types"; import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks"; import { useGameCard } from "@renderer/hooks";
import { memo } from "react"; import { memo, useState } from "react";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; import {
ClockIcon,
AlertFillIcon,
TrophyIcon,
ImageIcon,
} from "@primer/octicons-react";
import "./library-game-card.scss"; import "./library-game-card.scss";
interface LibraryGameCardProps { interface LibraryGameCardProps {
@@ -25,14 +30,9 @@ export const LibraryGameCard = memo(function LibraryGameCard({
const { formatPlayTime, handleCardClick, handleContextMenuClick } = const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu); useGameCard(game, onContextMenu);
const coverImage = ( const coverImage = game.coverImageUrl?.replaceAll("\\", "/") ?? "";
game.customIconUrl ??
game.coverImageUrl ?? const [imageError, setImageError] = useState(false);
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
""
).replaceAll("\\", "/");
return ( return (
<button <button
@@ -98,12 +98,19 @@ export const LibraryGameCard = memo(function LibraryGameCard({
)} )}
</div> </div>
<img {imageError || !coverImage ? (
src={coverImage ?? undefined} <div className="library-game-card__cover-placeholder">
alt={game.title} <ImageIcon size={48} />
className="library-game-card__game-image" </div>
loading="lazy" ) : (
/> <img
src={coverImage}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
onError={() => setImageError(true)}
/>
)}
</button> </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>
);
}

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