Compare commits

...

131 Commits

Author SHA1 Message Date
Zamitto
57c2e74013 chore: update ww sdk
Some checks are pending
Build / build (ubuntu-latest) (push) Waiting to run
Build / build (windows-2022) (push) Waiting to run
2026-01-24 20:54:54 -03:00
Chubby Granny Chaser
17528e74bb Merge pull request #1944 from anderlli0053/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Added Slovenian translation
2026-01-24 18:10:02 +00:00
Chubby Granny Chaser
355993b954 Merge branch 'main' into main 2026-01-24 18:09:44 +00:00
Chubby Granny Chaser
4aaaecbee5 Merge pull request #1939 from Wkeynhk/main
Fix RU translation
2026-01-24 18:08:56 +00:00
Chubby Granny Chaser
802b4bd26b Merge branch 'main' into main 2026-01-24 18:03:20 +00:00
Zamitto
baa2c8471a chore: bump ww version
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-21 18:26:17 -03:00
Moyase
63bb5ca511 Merge pull request #1948 from hydralauncher/fix/LBX-454
refactor: improve notification handling in SidebarProfile component
2026-01-21 21:05:46 +02:00
Moyase
073d3f25e3 Merge branch 'main' into fix/LBX-454 2026-01-21 18:24:44 +02:00
Moyase
066185e6ee Merge pull request #1947 from hydralauncher/feat/LBX-452
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: implement dynamic port discovery for Python RPC service
2026-01-21 18:24:25 +02:00
Moyase
8ce4b59294 Merge branch 'main' into feat/LBX-452 2026-01-21 18:09:16 +02:00
Moyase
90b62e4e8d Merge pull request #1946 from hydralauncher/feat/download-option-availability
feat: enhance repack availability status display with orb displaying availability
2026-01-21 18:09:03 +02:00
Moyase
8a447f683a Merge branch 'main' into feat/download-option-availability 2026-01-21 18:04:15 +02:00
Moyasee
5ddfd88ef7 refactor: remove polling to notifications count api 2026-01-21 11:33:42 +02:00
Moyasee
569ad1c862 chore: add get-port dependency and refactor Python RPC port handling 2026-01-21 11:28:14 +02:00
Chubby Granny Chaser
039df43123 Merge pull request #1945 from hydralauncher/feat/LBX-399
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: add automatic executable path binding upon download finish
2026-01-21 09:20:17 +00:00
Moyasee
50bafbb7f6 refactor: improve notification handling in SidebarProfile component 2026-01-20 19:41:24 +02:00
Moyasee
46154fa49a fix: correct error handling in Python RPC process exit code 2026-01-20 19:34:04 +02:00
Moyasee
aae35b591d feat: implement dynamic port discovery for Python RPC service 2026-01-20 19:25:32 +02:00
Moyasee
10ac6c9d9c style: update repacks modal styles for improved layout and positioning 2026-01-20 19:03:35 +02:00
Moyasee
9ca6a114b1 feat: enhance repack availability status display with new UI elements and translations 2026-01-20 18:52:52 +02:00
Moyasee
2108a523bc refactor: streamline game scanning logic and enhance notification handling 2026-01-19 18:01:55 +02:00
Moyasee
fbbb2520e0 feat: enhance game scanning notifications and UI updates 2026-01-19 17:57:49 +02:00
Moyasee
049a989e85 fix: deleted unnecessary import and fixed assertion 2026-01-19 15:19:50 +02:00
Moyasee
88b2581797 feat: add scan installed games functionality with UI integration 2026-01-19 15:17:27 +02:00
Moyasee
c9801644ac fix: prevent processing downloads without a folder name 2026-01-19 04:22:44 +02:00
Moyasee
98cfe7be98 feat: add automatic executable path binding upon download finish 2026-01-19 04:01:21 +02:00
Andrew Poženel
128f864ca7 Reformat the translation.json file 2026-01-18 23:02:46 +01:00
Andrew Poženel
a2b993bb9b Added Slovenian translation 2026-01-18 22:36:30 +01:00
Zamitto
7293afb618 Merge branch 'release/v3.8.0'
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-15 08:43:06 -03:00
Zamitto
194e7918ca feat: dont setup ww feedback widget if user has no token
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2026-01-15 08:42:33 -03:00
Zamitto
979958aca6 feat: update ww webRequest interceptor
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-14 19:37:17 -03:00
Wkeynhk
cafa536f79 Fix RU translation 2026-01-14 16:55:55 +03:00
Zamitto
6e92e0f79f fix: getLibrary throwing error
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-14 00:37:22 -03:00
Zamitto
aef069d4c7 Merge branch 'release/v3.8.1' 2026-01-14 00:07:53 -03:00
Zamitto
1f447cc478 chore: add sentry var to build-renderer action
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2026-01-14 00:05:55 -03:00
Zamitto
5d2dc3616c Merge pull request #1938 from hydralauncher/release/v3.8.1
sync main
2026-01-13 23:43:48 -03:00
Zamitto
1f9972f74e Merge pull request #1937 from hydralauncher/chore/add-sentry
chore: add sentry
2026-01-13 23:43:16 -03:00
Zamitto
3344f68408 feat: add semver for sentry 2026-01-13 23:42:22 -03:00
Zamitto
65be11cc07 chore: add sentry 2026-01-13 23:34:09 -03:00
Chubby Granny Chaser
7e78a0f9f1 chore: update version to 3.8.1 and enhance translations
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
- 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
Zamitto
96140e614c Merge pull request #1917 from hydralauncher/fix/friends-box-display
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
hotfix: add empty state for friends box and new translation key
2026-01-04 02:59:53 -03: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
88 changed files with 4972 additions and 908 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

@@ -42,6 +42,7 @@ jobs:
run: yarn build run: yarn build
env: env:
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
- name: Deploy to Cloudflare Pages - name: Deploy to Cloudflare Pages
env: env:

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

@@ -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",
@@ -40,6 +40,7 @@
"@primer/octicons-react": "^19.9.0", "@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/react": "^10.33.0",
"@tiptap/extension-bold": "^3.6.2", "@tiptap/extension-bold": "^3.6.2",
"@tiptap/extension-italic": "^3.6.2", "@tiptap/extension-italic": "^3.6.2",
"@tiptap/extension-link": "^3.6.2", "@tiptap/extension-link": "^3.6.2",
@@ -63,6 +64,7 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0", "file-type": "^20.5.0",
"framer-motion": "^12.15.0", "framer-motion": "^12.15.0",
"get-port": "^7.1.0",
"hls.js": "^1.5.12", "hls.js": "^1.5.12",
"i18next": "^23.11.2", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1", "i18next-browser-languagedetector": "^7.2.1",
@@ -91,6 +93,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.1.1",
"ws": "^8.18.1", "ws": "^8.18.1",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0" "yup": "^1.5.0"

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
try:
download = self.aria2.get_download(self.download.gid) 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

@@ -108,7 +108,17 @@
"search_results": "Search results", "search_results": "Search results",
"settings": "Settings", "settings": "Settings",
"version_available_install": "Version {{version}} available. Click here to restart and install.", "version_available_install": "Version {{version}} available. Click here to restart and install.",
"version_available_download": "Version {{version}} available. Click here to download." "version_available_download": "Version {{version}} available. Click here to download.",
"scan_games_tooltip": "Scan PC for installed games",
"scan_games_title": "Scan PC for installed games",
"scan_games_description": "This will scan your disks for known game executables. This may take several minutes.",
"scan_games_start": "Start Scan",
"scan_games_cancel": "Cancel",
"scan_games_result": "Found {{found}} of {{total}} games without executable path",
"scan_games_no_results": "We couldn't find any installed games.",
"scan_games_in_progress": "Scanning your disks for installed games...",
"scan_games_close": "Close",
"scan_games_scan_again": "Scan Again"
}, },
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": "No downloads in progress", "no_downloads_in_progress": "No downloads in progress",
@@ -175,6 +185,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",
@@ -184,6 +195,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",
@@ -365,6 +382,9 @@
"audio": "Audio", "audio": "Audio",
"filter_by_source": "Filter by source", "filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game", "no_repacks_found": "No sources found for this game",
"source_online": "Source is online",
"source_partial": "Some links are offline",
"source_offline": "Source is offline",
"delete_review": "Delete review", "delete_review": "Delete review",
"remove_review": "Remove Review", "remove_review": "Remove Review",
"delete_review_modal_title": "Are you sure you want to delete your review?", "delete_review_modal_title": "Are you sure you want to delete your review?",
@@ -397,6 +417,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…",
@@ -587,7 +611,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",
@@ -605,7 +632,11 @@
"game_extracted": "{{title}} extracted successfully", "game_extracted": "{{title}} extracted successfully",
"friend_started_playing_game": "{{displayName}} started playing a game", "friend_started_playing_game": "{{displayName}} started playing a game",
"test_achievement_notification_title": "This is a test notification", "test_achievement_notification_title": "This is a test notification",
"test_achievement_notification_description": "Pretty cool, huh?" "test_achievement_notification_description": "Pretty cool, huh?",
"scan_games_complete_title": "Scanning for games finished successfully",
"scan_games_complete_description": "Found {{count}} games without executable path set",
"scan_games_no_results_title": "Scanning for games finished",
"scan_games_no_results_description": "No installed games were found"
}, },
"system_tray": { "system_tray": {
"open": "Open Hydra", "open": "Open Hydra",
@@ -682,6 +713,7 @@
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"unblock": "Unblock", "unblock": "Unblock",
"no_friends_added": "You have no added friends", "no_friends_added": "You have no added friends",
"no_friends_yet": "You haven't added any friends yet",
"view_all": "View all", "view_all": "View all",
"load_more": "Load more", "load_more": "Load more",
"loading": "Loading", "loading": "Loading",
@@ -709,8 +741,15 @@
"profile_reported": "Profile reported", "profile_reported": "Profile reported",
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code", "copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
"replace_banner": "Replace banner",
"remove_banner": "Remove banner",
"remove_banner_modal_title": "Remove banner?",
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
"remove": "Remove",
"background_image_updated": "Background image updated", "background_image_updated": "Background image updated",
"stats": "Stats", "stats": "Stats",
"achievements": "achievements", "achievements": "achievements",
@@ -728,13 +767,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", "wrapped_2025": "Wrapped 2025"
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
}, },
"library": { "library": {
"library": "Library", "library": "Library",
@@ -795,6 +831,7 @@
"empty_description": "You're all caught up! Check back later for new updates.", "empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.", "empty_filter_description": "No notifications match this filter.",
"filter_all": "All", "filter_all": "All",
"filter_unread": "Unread",
"filter_friends": "Friends", "filter_friends": "Friends",
"filter_badges": "Badges", "filter_badges": "Badges",
"filter_upvotes": "Upvotes", "filter_upvotes": "Upvotes",

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

@@ -26,6 +26,7 @@
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés", "sign_in": "Bejelentkezés",
"friends": "Barátok", "friends": "Barátok",
"notifications": "Értesítések",
"need_help": "Elakadtál?", "need_help": "Elakadtál?",
"favorites": "Kedvenc Játékaim", "favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd", "playable_button_title": "Csak az azonnal játszható játékokat mutasd",
@@ -174,6 +175,7 @@
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg", "select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
"download_now": "Letöltés", "download_now": "Letöltés",
"loading": "Töltés...",
"no_shop_details": "A bolt adatai nem érhetőek el.", "no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók", "download_options": "Letöltési opciók",
"download_path": "Letöltési hely", "download_path": "Letöltési hely",
@@ -182,7 +184,13 @@
"screenshot": "Screenshot {{number}}", "screenshot": "Screenshot {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ", "open_screenshot": "{{number}} Screenshot megnyitása ",
"download_settings": "Letöltési beállítások", "download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód", "downloader": "Letöltő",
"downloader_online": "Elérhető",
"downloader_not_configured": "Elérhető de nincs beállítva",
"downloader_offline": "A link nem elérhető",
"downloader_not_available": "Nem elérhető",
"recommended": "Ajánlott",
"go_to_settings": "Beállítások megnyitása",
"select_executable": "Tallózás", "select_executable": "Tallózás",
"no_executable_selected": "Nincs futtatható fájl tallózva", "no_executable_selected": "Nincs futtatható fájl tallózva",
"open_folder": "Mappa megnyitása", "open_folder": "Mappa megnyitása",
@@ -418,9 +426,11 @@
"extract": "Fájlok kibontása", "extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…", "extracting": "Fájlok kibontása…",
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}", "delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ", "delete_archive_description": "A tömörített fájl ki lett csomagolva és többé nincs rá szükség.",
"yes": "Igen", "yes": "Igen",
"no": "Nem" "no": "Nem",
"network": "HÁLÓZAT",
"peak": "CSÚCS"
}, },
"settings": { "settings": {
"downloads_path": "Letöltési útvonalak", "downloads_path": "Letöltési útvonalak",
@@ -444,7 +454,7 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése", "save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve", "changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból, ennek az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés", "validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás", "remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása", "add_download_source": "Forrás hozáadása",
@@ -556,6 +566,7 @@
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése", "show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
"extract_files_by_default": "Fájlok kicsomagolása letöltés után", "extract_files_by_default": "Fájlok kicsomagolása letöltés után",
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése", "enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
"enable_new_download_options_badges": "Új letöltési helyek",
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése", "achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
"top-left": "Bal felső sarok", "top-left": "Bal felső sarok",
"top-center": "Felső közép", "top-center": "Felső közép",
@@ -636,9 +647,9 @@
"sort_by": "Rendezés:", "sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek", "achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva", "played_recently": "Nemrég játszva",
"playtime": "Játszottidő", "playtime": "Játékidő",
"total_play_time": "Teljes játszottidő", "total_play_time": "Teljes játékidő",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", "manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
"no_recent_activity_title": "Hmmm… itt semmi sincs", "no_recent_activity_title": "Hmmm… itt semmi sincs",
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!", "no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
"display_name": "Profilnév", "display_name": "Profilnév",
@@ -660,6 +671,7 @@
"sending": "Küldés..", "sending": "Küldés..",
"friend_request_sent": "Barátfelkérés elküldve", "friend_request_sent": "Barátfelkérés elküldve",
"friends": "Barátok", "friends": "Barátok",
"badges": "Kitűzők",
"friends_list": "Barát lista", "friends_list": "Barát lista",
"user_not_found": "Felhasználó nem találva", "user_not_found": "Felhasználó nem találva",
"block_user": "Felhasználó letiltása", "block_user": "Felhasználó letiltása",
@@ -670,12 +682,16 @@
"ignore_request": "Kérés ignorálása", "ignore_request": "Kérés ignorálása",
"cancel_request": "Kérés visszavonása", "cancel_request": "Kérés visszavonása",
"undo_friendship": "Barát eltávolítása", "undo_friendship": "Barát eltávolítása",
"friendship_removed": "Barát eltávolítva",
"request_accepted": "Barátfelkérés elfogadva", "request_accepted": "Barátfelkérés elfogadva",
"user_blocked_successfully": "Felhasználó sikeresen letiltva", "user_blocked_successfully": "Felhasználó sikeresen letiltva",
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}", "user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
"blocked_users": "Letiltott felhasználók", "blocked_users": "Letiltott felhasználók",
"unblock": "Tiltás feloldása", "unblock": "Tiltás feloldása",
"no_friends_added": "Nincs bejelölt barátod", "no_friends_added": "Nincs bejelölt barátod",
"view_all": "Összes megtekintése",
"load_more": "Több betöltése",
"loading": "Töltés..",
"pending": "Függőben", "pending": "Függőben",
"no_pending_invites": "Nincs függőben lévő barátfelkérésed", "no_pending_invites": "Nincs függőben lévő barátfelkérésed",
"no_blocked_users": "Nincs letiltott felhasználó", "no_blocked_users": "Nincs letiltott felhasználó",
@@ -699,6 +715,7 @@
"report_reason_other": "Egyéb", "report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve", "profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:", "your_friend_code": "A barát kódod:",
"copy_friend_code": "Barátkód kimásolása",
"upload_banner": "Borítókép feltöltése", "upload_banner": "Borítókép feltöltése",
"uploading_banner": "Borítókép feltöltése…", "uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve", "background_image_updated": "Borítókép frissítve",
@@ -718,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",
@@ -739,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"
@@ -772,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",
@@ -163,6 +164,7 @@
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download", "download_now": "Iniciar download",
"loading": "Carregando...",
"no_shop_details": "Não foi possível obter os detalhes da loja.", "no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download", "download_options": "Opções de download",
"download_path": "Diretório de download", "download_path": "Diretório de download",
@@ -172,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",
@@ -362,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",
@@ -384,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…",
@@ -457,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",
@@ -482,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",
@@ -544,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",
@@ -561,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",
@@ -568,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",
@@ -654,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",
@@ -666,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",
@@ -695,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",
@@ -720,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"
}, },
@@ -776,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": "Библиотеки успешно установлены"
@@ -148,7 +150,7 @@
"filter": "Поиск репаков", "filter": "Поиск репаков",
"requirements": "Системные требования", "requirements": "Системные требования",
"minimum": "Минимальные", "minimum": "Минимальные",
"recommended": "Рекомендуемые", "recommended": "Рекомендованные",
"paused": "Приостановлено", "paused": "Приостановлено",
"release_date": "Выпущено {{date}}", "release_date": "Выпущено {{date}}",
"publisher": "Издатель {{publisher}}", "publisher": "Издатель {{publisher}}",
@@ -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,11 @@
"open_screenshot": "Открыть скриншот {{number}}", "open_screenshot": "Открыть скриншот {{number}}",
"download_settings": "Параметры загрузки", "download_settings": "Параметры загрузки",
"downloader": "Загрузчик", "downloader": "Загрузчик",
"downloader_online": "Онлайн",
"downloader_not_configured": "Доступен, но не настроен",
"downloader_offline": "Ссылка недоступна",
"downloader_not_available": "Недоступно",
"go_to_settings": "Перейти в настройки",
"select_executable": "Выбрать", "select_executable": "Выбрать",
"no_executable_selected": "Файл не выбран", "no_executable_selected": "Файл не выбран",
"open_folder": "Открыть папку", "open_folder": "Открыть папку",
@@ -202,6 +210,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",
@@ -233,11 +242,11 @@
"show_more": "Показать больше", "show_more": "Показать больше",
"show_less": "Показать меньше", "show_less": "Показать меньше",
"reviews": "Отзывы", "reviews": "Отзывы",
"review_played_for": "Играли",
"leave_a_review": "Оставить отзыв", "leave_a_review": "Оставить отзыв",
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые", "sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов", "no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые", "sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл", "sort_highest_score": "Высший балл",
@@ -362,8 +371,6 @@
"audio": "Аудио", "audio": "Аудио",
"filter_by_source": "Фильтр по источнику", "filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены", "no_repacks_found": "Источники для этой игры не найдены",
"show": "Показать",
"hide": "Скрыть",
"delete_review": "Удалить отзыв", "delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв", "remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?", "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
@@ -375,7 +382,9 @@
"show_translation": "Показать перевод", "show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})", "show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал", "hide_original": "Скрыть оригинал",
"review_from_blocked_user": "Отзыв от заблокированного пользователя" "review_from_blocked_user": "Отзыв от заблокированного пользователя",
"show": "Показать",
"hide": "Скрыть"
}, },
"activation": { "activation": {
"title": "Активировать Hydra", "title": "Активировать Hydra",
@@ -394,6 +403,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 +427,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 +569,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 +587,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 +597,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 +677,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 +688,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": "Вы не заблокировали ни одного пользователя",
@@ -689,9 +721,17 @@
"report_reason_spam": "Спам", "report_reason_spam": "Спам",
"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,11 +749,31 @@
"game_added_to_pinned": "Игра добавлена в закрепленные", "game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма", "karma": "Карма",
"karma_count": "карма", "karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы", "user_reviews": "Отзывы",
"delete_review": "Удалить отзыв",
"loading_reviews": "Загрузка отзывов...", "loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов", "wrapped_2025": "Wrapped 2025"
"delete_review": "Удалить отзыв" },
"library": {
"library": "Библиотека",
"play": "Играть",
"download": "Скачать",
"downloading": "Скачивание",
"game": "игра",
"games": "игры",
"grid_view": "Вид сетки",
"compact_view": "Компактный вид",
"large_view": "Большой вид",
"no_games_title": "Ваша библиотека пуста",
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Достижение разблокировано", "achievement_unlocked": "Достижение разблокировано",
@@ -744,25 +804,40 @@
"learn_more": "Подробнее", "learn_more": "Подробнее",
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus" "debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
}, },
"library": { "notifications_page": {
"library": "Библиотека", "title": "Уведомления",
"play": "Играть", "mark_all_as_read": "Отметить все как прочитанные",
"download": "Скачать", "clear_all": "Очистить все",
"downloading": "Скачивание", "loading": "Загрузка...",
"game": "игра", "empty_title": "Нет уведомлений",
"games": "игры", "empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
"grid_view": "Вид сетки", "empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
"compact_view": "Компактный вид", "filter_all": "Все",
"large_view": "Большой вид", "filter_unread": "Непрочитанные",
"no_games_title": "Ваша библиотека пуста", "filter_friends": "Друзья",
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать", "filter_badges": "Значки",
"amount_hours": "{{amount}} часов", "filter_upvotes": "Голоса",
"amount_minutes": "{{amount}} минут", "filter_local": "Локальные",
"amount_hours_short": "{{amount}}ч", "load_more": "Загрузить еще",
"amount_minutes_short": "{{amount}}м", "dismiss": "Отклонить",
"manual_playtime_tooltip": "Время игры было обновлено вручную", "accept": "Принять",
"all_games": "Все игры", "refuse": "Отклонить",
"recently_played": "Недавно сыгранные", "notification": "Уведомление",
"favorites": "Избранное" "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

@@ -0,0 +1,844 @@
{
"language_name": "Slovenščina",
"app": {
"successfully_signed_in": "Uspešno ste se prijavili"
},
"home": {
"surprise_me": "Preseneti me",
"no_results": "Ni najdenih rezultatov",
"start_typing": "Začnite tipkati za iskanje...",
"hot": "Trenutno vroče",
"weekly": "📅 Najboljše igre tedna",
"achievements": "🏆 Igre za premagati"
},
"sidebar": {
"catalogue": "Katalog",
"library": "Knjižnica",
"downloads": "Prenosi",
"settings": "Nastavitve",
"my_library": "Moja knjižnica",
"downloading_metadata": "{{title}} (Prenos metapodatkov…)",
"paused": "{{title}} (V premoru)",
"downloading": "{{title}} ({{percentage}} - Prenos…)",
"filter": "Filtriraj knjižnico",
"home": "Domov",
"queued": "{{title}} (V čakalni vrsti)",
"game_has_no_executable": "Igra nima izbrane izvršljive datoteke",
"sign_in": "Prijavite se",
"friends": "Prijatelji",
"notifications": "Obvestila",
"need_help": "Potrebujete pomoč?",
"favorites": "Priljubljene",
"playable_button_title": "Pokaži le igre, ki jih lahko igrate zdaj",
"add_custom_game_tooltip": "Dodaj igro po meri",
"show_playable_only_tooltip": "Pokaži samo igrljive",
"custom_game_modal": "Dodaj igro po meri",
"custom_game_modal_description": "Dodajte igro po meri v vašo knjižnico z izbiro izvršljive datoteke",
"custom_game_modal_executable_path": "Pot do izvršljive datoteke",
"custom_game_modal_select_executable": "Izberite izvršljivo datoteko",
"custom_game_modal_title": "Naslov",
"custom_game_modal_enter_title": "Vnesite naslov",
"custom_game_modal_browse": "Brskaj",
"custom_game_modal_cancel": "Prekliči",
"custom_game_modal_add": "Dodaj igro",
"custom_game_modal_adding": "Dodajanje igre...",
"custom_game_modal_success": "Igra po meri je bila uspešno dodana",
"custom_game_modal_failed": "Dodajanje igre po meri ni uspelo",
"custom_game_modal_executable": "Izvršljiva datoteka",
"edit_game_modal": "Prilagodi sredstva",
"edit_game_modal_description": "Prilagodite sredstva in podrobnosti igre",
"edit_game_modal_title": "Naslov",
"edit_game_modal_enter_title": "Vnesite naslov",
"edit_game_modal_image": "Slika",
"edit_game_modal_select_image": "Izberite sliko",
"edit_game_modal_browse": "Brskaj",
"edit_game_modal_image_preview": "Predogled slike",
"edit_game_modal_icon": "Ikona",
"edit_game_modal_select_icon": "Izberite ikono",
"edit_game_modal_icon_preview": "Predogled ikone",
"edit_game_modal_logo": "Logotip",
"edit_game_modal_select_logo": "Izberite logotip",
"edit_game_modal_logo_preview": "Predogled logotipa",
"edit_game_modal_hero": "Hero knjižnice",
"edit_game_modal_select_hero": "Izberite sliko hero knjižnice",
"edit_game_modal_hero_preview": "Predogled hero slike knjižnice",
"edit_game_modal_cancel": "Prekliči",
"edit_game_modal_update": "Posodobi",
"edit_game_modal_updating": "Posodabljanje...",
"edit_game_modal_fill_required": "Prosimo, izpolnite vsa obvezna polja",
"edit_game_modal_success": "Sredstva so bila uspešno posodobljena",
"edit_game_modal_failed": "Posodabljanje sredstev ni uspelo",
"edit_game_modal_image_filter": "Slika",
"edit_game_modal_icon_resolution": "Priporočena resolucija: 256x256px",
"edit_game_modal_logo_resolution": "Priporočena resolucija: 640x360px",
"edit_game_modal_hero_resolution": "Priporočena resolucija: 1920x620px",
"edit_game_modal_assets": "Sredstva",
"edit_game_modal_drop_icon_image_here": "Spustite ikono tukaj",
"edit_game_modal_drop_logo_image_here": "Spustite logotip tukaj",
"edit_game_modal_drop_hero_image_here": "Spustite hero sliko tukaj",
"edit_game_modal_drop_to_replace_icon": "Spustite za zamenjavo ikone",
"edit_game_modal_drop_to_replace_logo": "Spustite za zamenjavo logotipa",
"edit_game_modal_drop_to_replace_hero": "Spustite za zamenjavo hero slike",
"install_decky_plugin": "Namesti Decky vtičnik",
"update_decky_plugin": "Posodobi Decky vtičnik",
"decky_plugin_installed_version": "Decky vtičnik (v{{version}})",
"install_decky_plugin_title": "Namesti Hydra Decky vtičnik",
"install_decky_plugin_message": "To bo preneslo in namestilo Hydra vtičnik za Decky Loader. To lahko zahteva povišane pravice. Nadaljujem?",
"update_decky_plugin_title": "Posodobi Hydra Decky vtičnik",
"update_decky_plugin_message": "Na voljo je nova različica Hydra Decky vtičnika. Ali želite posodobiti zdaj?",
"decky_plugin_installed": "Decky vtičnik v{{version}} je bil uspešno nameščen",
"decky_plugin_installation_failed": "Namestitev Decky vtičnika ni uspela: {{error}}",
"decky_plugin_installation_error": "Napaka pri nameščanju Decky vtičnika: {{error}}",
"confirm": "Potrdi",
"cancel": "Prekliči"
},
"header": {
"search": "Išči igre",
"search_library": "Išči v knjižnici",
"recent_searches": "Nedavna iskanja",
"suggestions": "Predlogi",
"clear_history": "Počisti",
"remove_from_history": "Odstrani iz zgodovine",
"loading": "Nalaganje...",
"no_results": "Ni rezultatov",
"home": "Domov",
"catalogue": "Katalog",
"library": "Knjižnica",
"downloads": "Prenosi",
"search_results": "Rezultati iskanja",
"settings": "Nastavitve",
"version_available_install": "Različica {{version}} je na voljo. Kliknite tukaj za ponovni zagon in namestitev.",
"version_available_download": "Različica {{version}} je na voljo. Kliknite tukaj za prenos."
},
"bottom_panel": {
"no_downloads_in_progress": "Ni prenosa v teku",
"downloading_metadata": "Prenos metapodatkov {{title}}…",
"downloading": "Prenos {{title}}… ({{percentage}} končano) - Čas {{eta}} - {{speed}}",
"calculating_eta": "Prenos {{title}}… ({{percentage}} končano) - Izračun preostalega časa…",
"checking_files": "Preverjanje datotek {{title}}… ({{percentage}} končano)",
"extracting": "Razpakiranje {{title}}… ({{percentage}} končano)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Namestitev zaključena",
"installation_complete_message": "Skupni redistributables so bili uspešno nameščeni"
},
"catalogue": {
"search": "Filtriraj…",
"developers": "Razvijalci",
"genres": "Žanri",
"tags": "Oznake",
"publishers": "Izdajatelji",
"download_sources": "Viri prenosa",
"result_count": "{{resultCount}} rezultatov",
"filter_count": "{{filterCount}} na voljo",
"clear_filters": "Počisti {{filterCount}} izbranih"
},
"game_details": {
"open_download_options": "Odpri možnosti prenosa",
"download_options_zero": "Ni možnosti prenosa",
"download_options_one": "{{count}} možnost prenosa",
"download_options_other": "{{count}} možnosti prenosa",
"updated_at": "Posodobljeno {{updated_at}}",
"install": "Namesti",
"resume": "Nadaljuj",
"pause": "Premor",
"cancel": "Prekliči",
"remove": "Odstrani",
"space_left_on_disk": "{{space}} prosto na disku",
"eta": "Zaključek {{eta}}",
"calculating_eta": "Izračun preostalega časa…",
"downloading_metadata": "Prenos metapodatkov…",
"filter": "Filtriraj repake",
"requirements": "Sistemske zahteve",
"minimum": "Minimum",
"recommended": "Priporočeno",
"paused": "V premoru",
"release_date": "Izid dne {{date}}",
"publisher": "Objavljeno s strani {{publisher}}",
"hours": "ur",
"minutes": "minut",
"amount_hours": "{{amount}} ur",
"amount_minutes": "{{amount}} minut",
"accuracy": "{{accuracy}}% natančnost",
"add_to_library": "Dodaj v knjižnico",
"already_in_library": "Že v knjižnici",
"remove_from_library": "Odstrani iz knjižnice",
"no_downloads": "Ni razpoložljivih prenosov",
"play_time": "Odigrano {{amount}}",
"last_time_played": "Nazadnje igrano {{period}}",
"not_played_yet": "Še niste igrali {{title}}",
"next_suggestion": "Naslednji predlog",
"play": "Igraj",
"deleting": "Brisanje namestitvenega programa…",
"close": "Zapri",
"playing_now": "Trenutno igranje",
"change": "Spremeni",
"repacks_modal_description": "Izberite repak, ki ga želite prenesti",
"select_folder_hint": "Za spremembo privzete mape pojdite v <0>Nastavitve</0>",
"download_now": "Prenesi zdaj",
"loading": "Nalaganje...",
"no_shop_details": "Podatkov o trgovini ni bilo mogoče pridobiti.",
"download_options": "Možnosti prenosa",
"download_path": "Pot prenosa",
"previous_screenshot": "Prejšnji posnetek zaslona",
"next_screenshot": "Naslednji posnetek zaslona",
"screenshot": "Posnetek zaslona {{number}}",
"open_screenshot": "Odpri posnetek zaslona {{number}}",
"download_settings": "Nastavitve prenosa",
"downloader": "Prenosnik",
"downloader_online": "Spletno",
"downloader_not_configured": "Na voljo, vendar ni nastavljeno",
"downloader_offline": "Povezava je brez povezave",
"downloader_not_available": "Ni na voljo",
"recommended": "Priporočeno",
"go_to_settings": "Pojdi v nastavitve",
"select_executable": "Izberi",
"no_executable_selected": "Ni izbrane izvršljive datoteke",
"open_folder": "Odpri mapo",
"open_download_location": "Poglej prenesene datoteke",
"create_shortcut": "Ustvari bližnjico na namizju",
"create_shortcut_simple": "Ustvari bližnjico",
"clear": "Počisti",
"remove_files": "Odstrani datoteke",
"remove_from_library_title": "Ali ste prepričani?",
"remove_from_library_description": "To bo odstranilo {{game}} iz vaše knjižnice",
"options": "Možnosti",
"properties": "Lastnosti",
"executable_section_title": "Izvršljiva datoteka",
"executable_section_description": "Pot do datoteke, ki se bo izvedla ob kliku na \"Igraj\"",
"downloads_section_title": "Prenosi",
"downloads_section_description": "Preverite posodobitve ali druge različice te igre",
"danger_zone_section_title": "Nevarno območje",
"danger_zone_section_description": "Odstranite to igro iz knjižnice ali datoteke, ki jih je prenesel Hydra",
"download_in_progress": "Prenos v teku",
"download_paused": "Prenos v premoru",
"extracting": "Razpakiranje",
"last_downloaded_option": "Zadnja prenesena možnost",
"new_download_option": "Novo",
"create_steam_shortcut": "Ustvari Steam bližnjico",
"create_shortcut_success": "Bližnjica je bila uspešno ustvarjena",
"you_might_need_to_restart_steam": "Morda boste morali ponovno zagnati Steam, da vidite spremembe",
"create_shortcut_error": "Napaka pri ustvarjanju bližnjice",
"add_to_favorites": "Dodaj med priljubljene",
"remove_from_favorites": "Odstrani iz priljubljenih",
"failed_update_favorites": "Posodabljanje priljubljenih ni uspelo",
"game_removed_from_library": "Igra odstranjena iz knjižnice",
"failed_remove_from_library": "Odstranjevanje iz knjižnice ni uspelo",
"files_removed_success": "Datoteke so bile uspešno odstranjene",
"failed_remove_files": "Odstranjevanje datotek ni uspelo",
"nsfw_content_title": "Ta igra vsebuje neprimerno vsebino",
"nsfw_content_description": "{{title}} vsebuje vsebino, ki morda ni primerna za vse starosti. Ali ste prepričani, da želite nadaljevati?",
"allow_nsfw_content": "Nadaljuj",
"refuse_nsfw_content": "Nazaj",
"stats": "Statistika",
"download_count": "Prenosi",
"player_count": "Aktivni igralci",
"rating_count": "Ocena",
"download_error": "Ta možnost prenosa ni na voljo",
"download": "Prenesi",
"executable_path_in_use": "Izvršljiva datoteka že uporablja \"{{game}}\"",
"warning": "Opozorilo:",
"hydra_needs_to_remain_open": "Za ta prenos mora Hydra ostati odprta, dokler ni končana. Če se Hydra zapre pred končanim prenosom, boste izgubili napredek.",
"achievements": "Dosežki",
"achievements_count": "Dosežki {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Pokaži več",
"show_less": "Pokaži manj",
"reviews": "Mnenja",
"review_played_for": "Odigrano za",
"leave_a_review": "Oddajte mnenje",
"write_review_placeholder": "Delite svoje misli o tej igri...",
"sort_newest": "Najnovejše",
"no_reviews_yet": "Ni še mnenj",
"be_first_to_review": "Bodite prvi, ki delite svoje misli o tej igri!",
"sort_oldest": "Najstarejše",
"sort_highest_score": "Najvišja ocena",
"sort_lowest_score": "Najnižja ocena",
"sort_most_voted": "Največ glasov",
"rating": "Ocena",
"rating_stats": "Ocena",
"rating_very_negative": "Zelo negativno",
"rating_negative": "Negativno",
"rating_neutral": "Nevtralno",
"rating_positive": "Pozitivno",
"rating_very_positive": "Zelo pozitivno",
"submit_review": "Pošlji",
"submitting": "Pošiljanje...",
"review_submitted_successfully": "Mnenje je bilo uspešno poslano!",
"review_submission_failed": "Pošiljanje mnenja ni uspelo. Prosimo, poskusite znova.",
"review_cannot_be_empty": "Polje mnenja ne sme biti prazno.",
"review_deleted_successfully": "Mnenje je bilo uspešno izbrisano.",
"review_deletion_failed": "Brisanje mnenja ni uspelo. Prosimo, poskusite znova.",
"loading_reviews": "Nalagam mnenja...",
"loading_more_reviews": "Nalagam več mnenj...",
"load_more_reviews": "Naloži več mnenj",
"you_seemed_to_enjoy_this_game": "Zdi se, da uživate v tej igri",
"would_you_recommend_this_game": "Bi radi oddali mnenje o tej igri?",
"yes": "Da",
"maybe_later": "Mogoče kasneje",
"cloud_save": "Shranjevanje v oblaku",
"cloud_save_description": "Shranjujte napredek v oblak in nadaljujte igranje na katerikoli napravi",
"backups": "Varnostne kopije",
"install_backup": "Namesti",
"delete_backup": "Izbriši",
"create_backup": "Nova varnostna kopija",
"last_backup_date": "Zadnja varnostna kopija {{date}}",
"no_backup_preview": "Ni shranjenih iger za ta naslov",
"restoring_backup": "Obnavljanje varnostne kopije ({{progress}} končano)…",
"uploading_backup": "Nalaganje varnostne kopije…",
"no_backups": "Za to igro še niste ustvarili varnostnih kopij",
"backup_uploaded": "Varnostna kopija naložena",
"backup_failed": "Varnostna kopija ni uspela",
"backup_deleted": "Varnostna kopija izbrisana",
"backup_restored": "Varnostna kopija obnovljena",
"see_all_achievements": "Poglej vse dosežke",
"sign_in_to_see_achievements": "Prijavite se za ogled dosežkov",
"mapping_method_automatic": "Samodejno",
"mapping_method_manual": "Ročno",
"mapping_method_label": "Način mapiranja",
"files_automatically_mapped": "Datoteke so samodejno preslikane",
"no_backups_created": "Za to igro ni ustvarjenih varnostnih kopij",
"manage_files": "Upravljaj datoteke",
"loading_save_preview": "Iskanje shranjenih iger…",
"wine_prefix": "Wine predpona",
"wine_prefix_description": "Wine predpona, uporabljena za zagon te igre",
"launch_options": "Možnosti zagona",
"launch_options_description": "Napredni uporabniki lahko vpišejo spremembe v možnosti zagona (eksperimentalna funkcija)",
"launch_options_placeholder": "Ni določenega parametra",
"no_download_option_info": "Ni razpoložljivih informacij",
"backup_deletion_failed": "Brisanje varnostne kopije ni uspelo",
"max_number_of_artifacts_reached": "Doseženo je največje število varnostnih kopij za to igro",
"achievements_not_sync": "Oglejte si, kako sinhronizirati svoje dosežke",
"manage_files_description": "Upravljajte, katere datoteke bodo varnostno kopirane in obnovljene",
"select_folder": "Izberite mapo",
"backup_from": "Varnostna kopija od {{date}}",
"automatic_backup_from": "Samodejna varnostna kopija od {{date}}",
"enable_automatic_cloud_sync": "Omogoči samodejno sinhronizacijo v oblaku",
"custom_backup_location_set": "Nastavljena je po meri lokacija varnostne kopije",
"no_directory_selected": "Ni izbrane mape",
"no_write_permission": "V to mapo ni mogoče prenesti. Kliknite tukaj za več informacij.",
"reset_achievements": "Ponastavi dosežke",
"reset_achievements_description": "To bo ponastavilo vse dosežke za {{game}}",
"reset_achievements_title": "Ali ste prepričani?",
"reset_achievements_success": "Dosežki so bili uspešno ponastavljeni",
"reset_achievements_error": "Ponastavitev dosežkov ni uspela",
"download_error_gofile_quota_exceeded": "Presegli ste mesečno kvoto Gofile. Prosimo, počakajte, da se kvota ponastavi.",
"download_error_real_debrid_account_not_authorized": "Vaš račun Real-Debrid ni pooblaščen za nove prenose. Preverite nastavitve računa in poskusite znova.",
"download_error_not_cached_on_real_debrid": "Ta prenos ni na voljo v Real-Debrid in preverjanje statusa prenosa iz Real-Debrid še ni na voljo.",
"update_playtime_title": "Posodobi čas igranja",
"update_playtime_description": "Ročno posodobite čas igranja za {{game}}",
"update_playtime": "Posodobi čas igranja",
"update_playtime_success": "Čas igranja je bil uspešno posodobljen",
"update_playtime_error": "Posodabljanje časa igranja ni uspelo",
"update_game_playtime": "Posodobi čas igranja igre",
"manual_playtime_warning": "Vaše ure bodo označene kot ročno posodobljene, tega ni mogoče razveljaviti.",
"manual_playtime_tooltip": "Ta čas igranja je bil ročno posodobljen",
"download_error_not_cached_on_torbox": "Ta prenos ni na voljo v TorBox in preverjanje statusa prenosa iz TorBox še ni na voljo.",
"download_error_not_cached_on_hydra": "Ta prenos ni na voljo v Nimbus.",
"game_removed_from_favorites": "Igra odstranjena iz priljubljenih",
"game_added_to_favorites": "Igra dodana med priljubljene",
"game_removed_from_pinned": "Igra odstranjena iz pripetih",
"game_added_to_pinned": "Igra pripeta",
"automatically_extract_downloaded_files": "Samodejno razpakiraj prenesene datoteke",
"create_start_menu_shortcut": "Ustvari bližnjico v Start meniju",
"invalid_wine_prefix_path": "Neveljavna pot Wine predpone",
"invalid_wine_prefix_path_description": "Pot do Wine predpone je neveljavna. Preverite pot in poskusite znova.",
"missing_wine_prefix": "Wine predpona je potrebna za ustvarjanje varnostne kopije na Linuxu",
"artifact_renamed": "Varnostna kopija je bila uspešno preimenovana",
"rename_artifact": "Preimenuj varnostno kopijo",
"rename_artifact_description": "Preimenujte varnostno kopijo v bolj opisno ime",
"artifact_name_label": "Ime varnostne kopije",
"artifact_name_placeholder": "Vnesite ime varnostne kopije",
"save_changes": "Shrani spremembe",
"required_field": "To polje je obvezno",
"max_length_field": "To polje mora biti krajše od {{length}} znakov",
"freeze_backup": "Pripni, da jo samodejne varnostne kopije ne prepišejo",
"unfreeze_backup": "Odpni",
"backup_frozen": "Varnostna kopija je pripeta",
"backup_unfrozen": "Varnostna kopija je odprijeta",
"backup_freeze_failed": "Pripenjanje varnostne kopije ni uspelo",
"backup_freeze_failed_description": "Morate pustiti vsaj en prost prostor za samodejne varnostne kopije",
"edit_game_modal_button": "Prilagodi sredstva igre",
"game_details": "Podrobnosti igre",
"currency_symbol": "$",
"currency_country": "us",
"prices": "Cene",
"no_prices_found": "Ni najdenih cen",
"view_all_prices": "Kliknite za ogled vseh cen",
"retail_price": "Maloprodajna cena",
"keyshop_price": "Cena v trgovini s ključki",
"historical_retail": "Zgodovinska maloprodajna cena",
"historical_keyshop": "Zgodovinska cena v trgovini s ključki",
"language": "Jezik",
"caption": "Naslov",
"audio": "Zvok",
"filter_by_source": "Filtriraj po viru",
"no_repacks_found": "Za to igro ni najdenih virov",
"delete_review": "Izbriši mnenje",
"remove_review": "Odstrani mnenje",
"delete_review_modal_title": "Ali ste prepričani, da želite izbrisati svoje mnenje?",
"delete_review_modal_description": "Tega dejanja ni mogoče razveljaviti.",
"delete_review_modal_delete_button": "Izbriši",
"delete_review_modal_cancel_button": "Prekliči",
"vote_failed": "Glasovanja ni uspelo. Prosimo, poskusite znova.",
"show_original": "Pokaži original",
"show_translation": "Pokaži prevod",
"show_original_translated_from": "Pokaži original (prevedeno iz {{language}})",
"hide_original": "Skrij original",
"review_from_blocked_user": "Mnenje blokiranega uporabnika",
"show": "Pokaži",
"hide": "Skrij"
},
"activation": {
"title": "Aktiviraj Hydra",
"installation_id": "ID namestitve:",
"enter_activation_code": "Vnesite aktivacijsko kodo",
"message": "Če ne veste, kje naj to pridobite, potem tega ne bi smeli imeti.",
"activate": "Aktiviraj",
"loading": "Nalaganje…"
},
"downloads": {
"resume": "Nadaljuj",
"pause": "Premor",
"eta": "Zaključek {{eta}}",
"paused": "V premoru",
"verifying": "Preverjanje…",
"completed": "Dokončano",
"removed": "Ni preneseno",
"cancel": "Prekliči",
"cancel_download": "Prekliči prenos?",
"cancel_download_description": "Ali ste prepričani, da želite prekiniti ta prenos? Vse prenesene datoteke bodo izbrisane.",
"keep_downloading": "Ne, nadaljuj prenos",
"yes_cancel": "Da, prekliči",
"filter": "Filtriraj prenesene igre",
"remove": "Odstrani",
"downloading_metadata": "Prenos metapodatkov…",
"deleting": "Brisanje namestitvenega programa…",
"delete": "Odstrani namestitveni program",
"delete_modal_title": "Ali ste prepričani?",
"delete_modal_description": "To bo odstranilo vse namestitvene datoteke z računalnika",
"install": "Namesti",
"download_in_progress": "V teku",
"queued_downloads": "Prenosi v čakalni vrsti",
"downloads_completed": "Dokončano",
"queued": "V čakalni vrsti",
"no_downloads_title": "Tako prazno",
"no_downloads_description": "Še niste prenesli ničesar z Hydra, a nikoli ni prepozno začeti.",
"checking_files": "Preverjanje datotek…",
"seeding": "Sejanje",
"stop_seeding": "Ustavi sejanje",
"resume_seeding": "Nadaljuj sejanje",
"options": "Upravljaj",
"extract": "Razpakiraj datoteke",
"extracting": "Razpakiranje datotek…",
"delete_archive_title": "Ali želite izbrisati {{fileName}}?",
"delete_archive_description": "Datoteka je bila uspešno razpakirana in ni več potrebna.",
"yes": "Da",
"no": "Ne",
"network": "OMREŽJE",
"peak": "VRH"
},
"settings": {
"downloads_path": "Pot prenosa",
"change": "Posodobi",
"notifications": "Obvestila",
"enable_download_notifications": "Ko je prenos končan",
"enable_repack_list_notifications": "Ko je dodan nov repack",
"real_debrid_api_token_label": "Real-Debrid API žeton",
"quit_app_instead_hiding": "Ne skrij Hydre pri zapiranju",
"launch_with_system": "Zaženi Hydra ob zagonu sistema",
"general": "Splošno",
"behavior": "Obnašanje",
"download_sources": "Viri prenosa",
"language": "Jezik",
"api_token": "API žeton",
"enable_real_debrid": "Omogoči Real-Debrid",
"real_debrid_description": "Real-Debrid je neomejen prenašalnik, ki vam omogoča hitro prenašanje datotek, omejeno le s hitrostjo vašega interneta.",
"debrid_invalid_token": "Neveljaven API žeton",
"debrid_api_token_hint": "Žeton API lahko dobite <0>tukaj</0>",
"real_debrid_free_account_error": "Račun \"{{username}}\" je brezplačen. Prosimo, naročite se na Real-Debrid",
"debrid_linked_message": "Račun \"{{username}}\" povezan",
"save_changes": "Shrani spremembe",
"changes_saved": "Spremembe uspešno shranjene",
"download_sources_description": "Hydra bo pridobila povezave za prenos iz teh virov. URL vira mora biti neposredna povezava do .json datoteke, ki vsebuje povezave za prenos.",
"validate_download_source": "Preveri",
"remove_download_source": "Odstrani",
"add_download_source": "Dodaj vir",
"adding": "Dodajanje…",
"failed_add_download_source": "Dodajanje vira za prenos ni uspelo. Poskusite znova.",
"download_source_already_exists": "Ta URL vira za prenos že obstaja.",
"download_count_zero": "Ni možnosti prenosa",
"download_count_one": "{{countFormatted}} možnost prenosa",
"download_count_other": "{{countFormatted}} možnosti prenosa",
"download_source_url": "URL vira za prenos",
"add_download_source_description": "Vstavite URL .json datoteke",
"download_source_up_to_date": "Posodobljeno",
"download_source_errored": "Napaka",
"download_source_pending_matching": "Kmalu posodobljeno",
"download_source_matched": "Posodobljeno",
"download_source_matching": "Posodabljanje",
"download_source_failed": "Napaka",
"download_source_no_information": "Ni podatkov na voljo",
"sync_download_sources": "Sinhroniziraj vire",
"removed_download_source": "Vir prenosa odstranjen",
"removed_download_sources": "Viri prenosa odstranjeni",
"removed_all_download_sources": "Vsi viri prenosa odstranjeni",
"download_sources_synced_successfully": "Vsi viri prenosa so sinhronizirani",
"cancel_button_confirmation_delete_all_sources": "Ne",
"confirm_button_confirmation_delete_all_sources": "Da, izbriši vse",
"title_confirmation_delete_all_sources": "Izbriši vse vire prenosa",
"description_confirmation_delete_all_sources": "Izbrišete vse vire prenosa",
"button_delete_all_sources": "Odstrani vse",
"added_download_source": "Vir prenosa dodan",
"download_sources_synced": "Vsi viri prenosa so sinhronizirani",
"insert_valid_json_url": "Vnesite veljaven JSON URL",
"found_download_option_zero": "Ni možnosti prenosa",
"found_download_option_one": "Najdena {{countFormatted}} možnost prenosa",
"found_download_option_other": "Najdenih {{countFormatted}} možnosti prenosa",
"import": "Uvozi",
"importing": "Uvažanje...",
"public": "Javno",
"private": "Zasebno",
"friends_only": "Samo prijatelji",
"privacy": "Zasebnost",
"profile_visibility": "Vidnost profila",
"profile_visibility_description": "Izberite, kdo lahko vidi vaš profil in knjižnico",
"required_field": "To polje je obvezno",
"source_already_exists": "Ta vir je že bil dodan",
"must_be_valid_url": "Vir mora biti veljaven URL",
"blocked_users": "Blokirani uporabniki",
"user_unblocked": "Uporabnik je odblokiran",
"enable_achievement_notifications": "Ko je dosežek odklenjen",
"launch_minimized": "Zaženi Hydra minimizirano",
"disable_nsfw_alert": "Onemogoči opozorilo NSFW",
"seed_after_download_complete": "Sejanje po končanem prenosu",
"show_hidden_achievement_description": "Pokaži opis skritih dosežkov pred njihovim odklepanjem",
"account": "Račun",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "Nimate blokiranih uporabnikov",
"subscription_active_until": "Vaš Hydra Cloud je aktiven do {{date}}",
"manage_subscription": "Upravljaj naročnino",
"update_email": "Posodobi e-pošto",
"update_password": "Posodobi geslo",
"current_email": "Trenutna e-pošta:",
"no_email_account": "Še niste nastavili e-pošte",
"account_data_updated_successfully": "Podatki računa so bili uspešno posodobljeni",
"renew_subscription": "Obnovi Hydra Cloud",
"subscription_expired_at": "Vaša naročnina je potekla {{date}}",
"no_subscription": "Uživajte v Hydri na najboljši način",
"become_subscriber": "Postanite Hydra Cloud uporabnik",
"subscription_renew_cancelled": "Samodejno podaljševanje je onemogočeno",
"subscription_renews_on": "Vaša naročnina se podaljša {{date}}",
"bill_sent_until": "Naslednji račun bo poslan do tega dne",
"no_themes": "Zdi se, da še nimate tem, vendar brez skrbi, kliknite tukaj, da ustvarite svojo prvo mojstrovino.",
"editor_tab_code": "Koda",
"editor_tab_info": "Info",
"editor_tab_save": "Shrani",
"web_store": "Spletna trgovina",
"clear_themes": "Počisti",
"create_theme": "Ustvari",
"create_theme_modal_title": "Ustvari prilagojeno temo",
"create_theme_modal_description": "Ustvarite novo temo za prilagajanje videza Hydre",
"theme_name": "Ime",
"insert_theme_name": "Vstavite ime teme",
"set_theme": "Nastavi temo",
"unset_theme": "Odstrani temo",
"delete_theme": "Izbriši temo",
"edit_theme": "Uredi temo",
"delete_all_themes": "Izbriši vse teme",
"delete_all_themes_description": "To bo izbrisalo vse vaše prilagojene teme",
"delete_theme_description": "To bo izbrisalo temo {{theme}}",
"cancel": "Prekliči",
"appearance": "Videz",
"debrid": "Debrid",
"debrid_description": "Debrid storitve so premium neomejeni prenašalniki, ki vam omogočajo hitro prenašanje datotek, gostovanih na različnih storitvah za gostovanje datotek, omejeno le s hitrostjo vašega interneta.",
"enable_torbox": "Omogoči TorBox",
"torbox_description": "TorBox je vaša premium seedbox storitev, ki se lahko kosuje tudi najboljšim strežnikom na trgu.",
"torbox_account_linked": "TorBox račun povezan",
"create_real_debrid_account": "Kliknite tukaj, če še nimate Real-Debrid računa",
"create_torbox_account": "Kliknite tukaj, če še nimate TorBox računa",
"real_debrid_account_linked": "Real-Debrid račun povezan",
"name_min_length": "Ime teme mora imeti vsaj 3 znake",
"import_theme": "Uvozi temo",
"import_theme_description": "Uvožili boste {{theme}} iz trgovine tem",
"error_importing_theme": "Napaka pri uvozu teme",
"theme_imported": "Tema uspešno uvožena",
"enable_friend_request_notifications": "Ko je prejet prijateljski zahtevek",
"enable_auto_install": "Samodejno prenesi posodobitve",
"common_redist": "Skupni redistributable-ji",
"common_redist_description": "Skupni redistributable-ji so potrebni za zagon nekaterih iger. Priporočamo njihovo namestitev, da se izognete težavam.",
"install_common_redist": "Namesti",
"installing_common_redist": "Nameščanje…",
"show_download_speed_in_megabytes": "Pokaži hitrost prenosa v megabajtih na sekundo",
"extract_files_by_default": "Privzeto razpakiraj datoteke po prenosu",
"enable_steam_achievements": "Omogoči iskanje po Steam dosežkih",
"enable_new_download_options_badges": "Pokaži značke novih možnosti prenosa",
"achievement_custom_notification_position": "Lastna pozicija obvestil o dosežkih",
"top-left": "Zgoraj levo",
"top-center": "Zgoraj na sredini",
"top-right": "Zgoraj desno",
"bottom-left": "Spodaj levo",
"bottom-center": "Spodaj na sredini",
"bottom-right": "Spodaj desno",
"enable_achievement_custom_notifications": "Omogoči lastna obvestila o dosežkih",
"alignment": "Poravnava",
"variation": "Variacija",
"default": "Privzeto",
"rare": "Redko",
"platinum": "Platinasto",
"hidden": "Skrito",
"test_notification": "Preizkusno obvestilo",
"achievement_sound_volume": "Glasnost zvoka dosežka",
"select_achievement_sound": "Izberite zvok dosežka",
"change_achievement_sound": "Spremeni zvok dosežka",
"remove_achievement_sound": "Odstrani zvok dosežka",
"preview_sound": "Predogled zvoka",
"select": "Izberi",
"preview": "Predogled",
"remove": "Odstrani",
"no_sound_file_selected": "Nobena zvočna datoteka ni izbrana",
"notification_preview": "Predogled obvestila o dosežku",
"enable_friend_start_game_notifications": "Ko prijatelj začne igrati igro",
"autoplay_trailers_on_game_page": "Samodejno predvajaj napovednike na strani igre",
"hide_to_tray_on_game_start": "Skrij Hydreo v sistemsko vrstico ob zagonu igre",
"downloads": "Prenosi",
"use_native_http_downloader": "Uporabi izvorni HTTP prenašalnik (eksperimentalno)",
"cannot_change_downloader_while_downloading": "Nastavitve ni mogoče spremeniti med prenosom",
"notifications": {
"download_complete": "Prenos končan",
"game_ready_to_install": "{{title}} je pripravljen za namestitev",
"repack_list_updated": "Seznam repackov posodobljen",
"repack_count_one": "{{count}} repack dodan",
"repack_count_other": "{{count}} repackov dodanih",
"new_update_available": "Različica {{version}} na voljo",
"restart_to_install_update": "Znova zaženite Hydreo za namestitev posodobitve",
"notification_achievement_unlocked_title": "Dosežek odklenjen za {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} in drugi {{count}} so bili odklenjeni",
"new_friend_request_description": "{{displayName}} vam je poslal prijateljsko zahtevo",
"new_friend_request_title": "Nova prijateljska zahteva",
"extraction_complete": "Razpakiranje končano",
"game_extracted": "{{title}} je bil uspešno razpakiran",
"friend_started_playing_game": "{{displayName}} je začel igrati igro",
"test_achievement_notification_title": "To je preizkusno obvestilo",
"test_achievement_notification_description": "Kar kul, kajne?"
},
"system_tray": {
"open": "Odpri Hydreo",
"quit": "Izhod"
},
"game_card": {
"available_one": "Na voljo",
"available_other": "Na voljo",
"no_downloads": "Ni razpoložljivih prenosov",
"calculating": "Računam"
},
"binary_not_found_modal": {
"title": "Programi niso nameščeni",
"description": "Izvajalniki Wine ali Lutris niso bili najdeni na vašem sistemu",
"instructions": "Preverite pravi način za namestitev katerega od njih na vašo Linux distribucijo, da bi igra lahko normalno tekla"
},
"modal": {
"close": "Zapri gumb"
},
"forms": {
"toggle_password_visibility": "Preklopi vidnost gesla"
},
"user_profile": {
"amount_hours": "{{amount}} ur",
"amount_minutes": "{{amount}} minut",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Nazadnje igrano {{period}}",
"activity": "Nedavna dejavnost",
"library": "Knjižnica",
"pinned": "Pripeto",
"sort_by": "Razvrsti po:",
"achievements_earned": "Odklenjeni dosežki",
"played_recently": "Nazadnje igrano",
"playtime": "Čas igranja",
"total_play_time": "Skupni čas igranja",
"manual_playtime_tooltip": "Ta čas igranja je bil ročno posodobljen",
"no_recent_activity_title": "Hmmm… nič tukaj",
"no_recent_activity_description": "Niste igrali nobene igre v zadnjem času. Čas je, da to spremenite!",
"display_name": "Prikazno ime",
"saving": "Shranjevanje",
"save": "Shrani",
"edit_profile": "Uredi profil",
"saved_successfully": "Uspešno shranjeno",
"try_again": "Prosimo, poskusite znova",
"sign_out_modal_title": "Ste prepričani?",
"cancel": "Prekliči",
"successfully_signed_out": "Uspešno odjavljeni",
"sign_out": "Odjavi se",
"playing_for": "Igra za {{amount}}",
"sign_out_modal_text": "Vaša knjižnica je povezana s trenutnim računom. Ob odjavi knjižnica ne bo več vidna, napredek pa se ne bo shranil. Nadaljujete z odjavo?",
"add_friends": "Dodaj prijatelje",
"add": "Dodaj",
"friend_code": "Koda prijatelja",
"see_profile": "Poglej profil",
"sending": "Pošiljanje",
"friend_request_sent": "Zahteva za prijateljstvo poslana",
"friends": "Prijatelji",
"badges": "Značke",
"friends_list": "Seznam prijateljev",
"user_not_found": "Uporabnik ni najden",
"block_user": "Blokiraj uporabnika",
"add_friend": "Dodaj prijatelja",
"request_sent": "Zahteva poslana",
"request_received": "Zahteva prejeta",
"accept_request": "Sprejmi zahtevo",
"ignore_request": "Ignoriraj zahtevo",
"cancel_request": "Prekliči zahtevo",
"undo_friendship": "Razveljavi prijateljstvo",
"friendship_removed": "Prijatelj odstranjen",
"request_accepted": "Zahteva sprejeta",
"user_blocked_successfully": "Uporabnik uspešno blokiran",
"user_block_modal_text": "To bo blokiralo {{displayName}}",
"blocked_users": "Blokirani uporabniki",
"unblock": "Odblokiraj",
"no_friends_added": "Nimate dodanih prijateljev",
"no_friends_yet": "Še niste dodali prijateljev",
"view_all": "Poglej vse",
"load_more": "Naloži več",
"loading": "Nalaganje",
"pending": "V teku",
"no_pending_invites": "Nimate čakajočih povabil",
"no_blocked_users": "Nimate blokiranih uporabnikov",
"friend_code_copied": "Koda prijatelja kopirana",
"undo_friendship_modal_text": "To bo razveljavilo vaše prijateljstvo z {{displayName}}",
"privacy_hint": "Za prilagoditev, kdo to vidi, pojdite na <0>Nastavitve</0>",
"locked_profile": "Ta profil je zaseben",
"image_process_failure": "Napaka pri obdelavi slike",
"required_field": "To polje je obvezno",
"displayname_min_length": "Prikazno ime mora biti dolgo vsaj 3 znake",
"displayname_max_length": "Prikazno ime mora imeti največ 50 znakov",
"report_profile": "Prijavi ta profil",
"report_reason": "Zakaj prijavljate ta profil?",
"report_description": "Dodatne informacije",
"report_description_placeholder": "Dodatne informacije",
"report": "Prijavi",
"report_reason_hate": "Sovražni govor",
"report_reason_sexual_content": "Seksualna vsebina",
"report_reason_violence": "Nasilje",
"report_reason_spam": "Spam",
"report_reason_other": "Drugo",
"profile_reported": "Profil prijavljen",
"your_friend_code": "Vaša koda prijatelja:",
"copy_friend_code": "Kopiraj kodo prijatelja",
"copied": "Kopirano!",
"upload_banner": "Naloži banner",
"uploading_banner": "Nalaganje bannerja…",
"change_banner": "Spremeni banner",
"replace_banner": "Zamenjaj banner",
"remove_banner": "Odstrani banner",
"remove_banner_modal_title": "Odstrani banner?",
"remove_banner_confirmation": "Ali ste prepričani, da želite odstraniti banner? Kadarkoli lahko izberete novega.",
"remove": "Odstrani",
"background_image_updated": "Pozadinska slika posodobljena",
"stats": "Statistika",
"achievements": "dosežki",
"games": "Igre",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "Uvrstitev se posodablja tedensko",
"playing": "Igra {{game}}",
"achievements_unlocked": "Dosežki odklenjeni",
"earned_points": "Zaslužene točke",
"show_achievements_on_profile": "Pokaži vaše dosežke na profilu",
"show_points_on_profile": "Pokaži vaše zaslužene točke na profilu",
"error_adding_friend": "Zahteve za prijatelja ni bilo mogoče poslati. Preverite kodo prijatelja",
"friend_code_length_error": "Koda prijatelja mora vsebovati 8 znakov",
"game_removed_from_pinned": "Igra odstranjena iz pripetih",
"game_added_to_pinned": "Igra dodana med pripete",
"karma": "Karma",
"karma_count": "karma",
"user_reviews": "Mnenja",
"delete_review": "Izbriši mnenje",
"loading_reviews": "Nalaganje mnenj...",
"wrapped_2025": "Wrapped 2025"
},
"library": {
"library": "Knjižnica",
"play": "Igraj",
"download": "Prenesi",
"downloading": "Prenašanje",
"game": "igra",
"games": "igre",
"grid_view": "Mrežni pogled",
"compact_view": "Kompaktni pogled",
"large_view": "Velik pogled",
"no_games_title": "Vaša knjižnica je prazna",
"no_games_description": "Dodajte igre iz kataloga ali jih prenesite, da začnete",
"amount_hours": "{{amount}} ur",
"amount_minutes": "{{amount}} minut",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "Ta čas igranja je bil ročno posodobljen",
"all_games": "Vse igre",
"recently_played": "Nedavno igrane",
"favorites": "Priljubljene"
},
"achievement": {
"achievement_unlocked": "Dosežek odklenjen",
"user_achievements": "Dosežki uporabnika {{displayName}}",
"your_achievements": "Vaši dosežki",
"unlocked_at": "Odklenjeno: {{date}}",
"subscription_needed": "Naročnina na Hydra Cloud je potrebna za ogled te vsebine",
"new_achievements_unlocked": "Odklenili ste {{achievementCount}} novih dosežkov iz {{gameCount}} iger",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} dosežkov",
"achievements_unlocked_for_game": "Odklenili ste {{achievementCount}} novih dosežkov za {{gameTitle}}",
"hidden_achievement_tooltip": "To je skriti dosežek",
"achievement_earn_points": "Z zaslužite {{points}} točk s tem dosežkom",
"earned_points": "Zaslužene točke:",
"available_points": "Razpoložljive točke:",
"how_to_earn_achievements_points": "Kako zaslužiti točke za dosežke?"
},
"hydra_cloud": {
"subscription_tour_title": "Naročnina Hydra Cloud",
"subscribe_now": "Naroči se zdaj",
"cloud_saving": "Shranjevanje v oblak",
"cloud_achievements": "Shrani svoje dosežke v oblak",
"animated_profile_picture": "Animirane profilne slike",
"premium_support": "Premium podpora",
"show_and_compare_achievements": "Pokaži in primerjaj svoje dosežke z drugimi uporabniki",
"animated_profile_banner": "Animirani profilni banner",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "Pravkar ste odkrili funkcijo Hydra Cloud!",
"learn_more": "Več informacij",
"debrid_description": "Prenesite do 4x hitreje z Nimbusom"
},
"notifications_page": {
"title": "Obvestila",
"mark_all_as_read": "Označi vse kot prebrano",
"clear_all": "Počisti vse",
"loading": "Nalagam...",
"empty_title": "Ni obvestil",
"empty_description": "Ste na tekočem! Preverite kasneje za nove posodobitve.",
"empty_filter_description": "Nobeno obvestilo ne ustreza tem filtram.",
"filter_all": "Vse",
"filter_unread": "Neprebrano",
"filter_friends": "Prijatelji",
"filter_badges": "Značke",
"filter_upvotes": "Glasovi za všečkanje",
"filter_local": "Lokalno",
"load_more": "Naloži več",
"dismiss": "Opusti",
"accept": "Sprejmi",
"refuse": "Zavrni",
"notification": "Obvestilo",
"friend_request_received_title": "Nova prijateljska zahteva!",
"friend_request_received_description": "{{displayName}} želi biti vaš prijatelj",
"friend_request_accepted_title": "Zahteva za prijateljstvo sprejeta!",
"friend_request_accepted_description": "{{displayName}} je sprejel vašo zahtevo",
"badge_received_title": "Prejeli ste novo značko!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Vaša recenzija za {{gameTitle}} je dobila glasove!",
"review_upvote_description": "Vaša recenzija je dobila {{count}} novih glasov",
"marked_all_as_read": "Vsa obvestila označena kot prebrana",
"failed_to_mark_as_read": "Neuspešno označevanje obvestil kot prebranih",
"cleared_all": "Vsa obvestila izbrisana",
"failed_to_clear": "Neuspešno brisanje obvestil",
"failed_to_load": "Neuspešno nalaganje obvestil",
"failed_to_dismiss": "Neuspešno opustitev obvestila",
"friend_request_accepted": "Zahteva za prijateljstvo sprejeta",
"friend_request_refused": "Zahteva za prijateljstvo zavrnjena"
}
}
}

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

@@ -25,7 +25,7 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const achievements = await gameAchievementsSublevel.get(key); const achievements = await gameAchievementsSublevel.get(key);
unlockedAchievementCount = unlockedAchievementCount =
achievements?.unlockedAchievements.length ?? 0; achievements?.unlockedAchievements?.length ?? 0;
} }
return { return {

View File

@@ -13,6 +13,7 @@ import "./delete-game-folder";
import "./extract-game-download"; import "./extract-game-download";
import "./get-default-wine-prefix-selection-path"; import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id"; import "./get-game-by-object-id";
import "./get-game-installer-action-type";
import "./get-library"; import "./get-library";
import "./open-game-executable-path"; import "./open-game-executable-path";
import "./open-game-installer-path"; import "./open-game-installer-path";
@@ -23,6 +24,7 @@ import "./remove-game-from-favorites";
import "./remove-game-from-library"; import "./remove-game-from-library";
import "./remove-game"; import "./remove-game";
import "./reset-game-achievements"; import "./reset-game-achievements";
import "./scan-installed-games";
import "./select-game-wine-prefix"; import "./select-game-wine-prefix";
import "./toggle-automatic-cloud-sync"; import "./toggle-automatic-cloud-sync";
import "./toggle-game-pin"; import "./toggle-game-pin";

View File

@@ -0,0 +1,143 @@
import path from "node:path";
import fs from "node:fs";
import { t } from "i18next";
import { registerEvent } from "../register-event";
import { gamesSublevel } from "@main/level";
import {
GameExecutables,
LocalNotificationManager,
logger,
WindowManager,
} from "@main/services";
const SCAN_DIRECTORIES = [
String.raw`C:\Games`,
String.raw`D:\Games`,
String.raw`C:\Program Files (x86)\Steam\steamapps\common`,
String.raw`C:\Program Files\Steam\steamapps\common`,
String.raw`C:\Program Files (x86)\DODI-Repacks`,
];
interface FoundGame {
title: string;
executablePath: string;
}
interface ScanResult {
foundGames: FoundGame[];
total: number;
}
async function searchInDirectories(
executableNames: Set<string>
): Promise<string | null> {
for (const scanDir of SCAN_DIRECTORIES) {
if (!fs.existsSync(scanDir)) continue;
const foundPath = await findExecutableInFolder(scanDir, executableNames);
if (foundPath) return foundPath;
}
return null;
}
async function publishScanNotification(foundCount: number): Promise<void> {
const hasFoundGames = foundCount > 0;
await LocalNotificationManager.createNotification(
"SCAN_GAMES_COMPLETE",
t(
hasFoundGames
? "scan_games_complete_title"
: "scan_games_no_results_title",
{ ns: "notifications" }
),
t(
hasFoundGames
? "scan_games_complete_description"
: "scan_games_no_results_description",
{ ns: "notifications", count: foundCount }
),
{ url: "/library?openScanModal=true" }
);
}
const scanInstalledGames = async (
_event: Electron.IpcMainInvokeEvent
): Promise<ScanResult> => {
const games = await gamesSublevel
.iterator()
.all()
.then((results) =>
results
.filter(
([_key, game]) => game.isDeleted === false && game.shop !== "custom"
)
.map(([key, game]) => ({ key, game }))
);
const foundGames: FoundGame[] = [];
const gamesToScan = games.filter((g) => !g.game.executablePath);
for (const { key, game } of gamesToScan) {
const executableNames = GameExecutables.getExecutablesForGame(
game.objectId
);
if (!executableNames || executableNames.length === 0) continue;
const normalizedNames = new Set(
executableNames.map((name) => name.toLowerCase())
);
const foundPath = await searchInDirectories(normalizedNames);
if (foundPath) {
await gamesSublevel.put(key, { ...game, executablePath: foundPath });
logger.info(
`[ScanInstalledGames] Found executable for ${game.objectId}: ${foundPath}`
);
foundGames.push({ title: game.title, executablePath: foundPath });
}
}
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
await publishScanNotification(foundGames.length);
return { foundGames, total: gamesToScan.length };
};
async function findExecutableInFolder(
folderPath: string,
executableNames: Set<string>
): Promise<string | null> {
try {
const entries = await fs.promises.readdir(folderPath, {
withFileTypes: true,
recursive: true,
});
for (const entry of entries) {
if (!entry.isFile()) continue;
const fileName = entry.name.toLowerCase();
if (executableNames.has(fileName)) {
const parentPath =
"parentPath" in entry ? entry.parentPath : folderPath;
return path.join(parentPath, entry.name);
}
}
} catch (err) {
logger.error(
`[ScanInstalledGames] Error reading folder ${folderPath}:`,
err
);
}
return null;
}
registerEvent("scanInstalledGames", scanInstalledGames);

View File

@@ -51,7 +51,10 @@ const updateProfile = async (
"backgroundImageUrl", "backgroundImageUrl",
]); ]);
if (updateProfile.profileImageUrl) { if (updateProfile.profileImageUrl !== undefined) {
if (updateProfile.profileImageUrl === null) {
payload["profileImageUrl"] = null;
} else {
const profileImageUrl = await uploadImage( const profileImageUrl = await uploadImage(
"profile-image", "profile-image",
updateProfile.profileImageUrl updateProfile.profileImageUrl
@@ -59,8 +62,12 @@ const updateProfile = async (
payload["profileImageUrl"] = profileImageUrl; payload["profileImageUrl"] = profileImageUrl;
} }
}
if (updateProfile.backgroundImageUrl) { if (updateProfile.backgroundImageUrl !== undefined) {
if (updateProfile.backgroundImageUrl === null) {
payload["backgroundImageUrl"] = null;
} else {
const backgroundImageUrl = await uploadImage( const backgroundImageUrl = await uploadImage(
"background-image", "background-image",
updateProfile.backgroundImageUrl updateProfile.backgroundImageUrl
@@ -68,6 +75,7 @@ const updateProfile = async (
payload["backgroundImageUrl"] = backgroundImageUrl; payload["backgroundImageUrl"] = backgroundImageUrl;
} }
}
return patchUserProfile(payload); return patchUserProfile(payload);
}; };

View File

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

View File

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

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,7 +18,7 @@ import {
} from "./types"; } from "./types";
import { calculateETA, getDirSize } from "./helpers"; import { calculateETA, getDirSize } from "./helpers";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import path from "path"; import path from "node:path";
import { logger } from "../logger"; import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
@@ -25,9 +26,13 @@ import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager"; import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid"; import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
import { JsHttpDownloader } from "./js-http-downloader";
export class DownloadManager { export class DownloadManager {
private static downloadingGameId: string | null = null; private static downloadingGameId: string | null = null;
private static jsDownloader: JsHttpDownloader | null = null;
private static usingJsDownloader = false;
private static isPreparingDownload = false;
private static extractFilename( private static extractFilename(
url: string, url: string,
@@ -35,18 +40,27 @@ export class DownloadManager {
): string | undefined { ): string | undefined {
if (originalUrl?.includes("#")) { if (originalUrl?.includes("#")) {
const hashPart = originalUrl.split("#")[1]; const hashPart = originalUrl.split("#")[1];
if (hashPart && !hashPart.startsWith("http")) return hashPart; if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
} }
if (url.includes("#")) { if (url.includes("#")) {
const hashPart = url.split("#")[1]; const hashPart = url.split("#")[1];
if (hashPart && !hashPart.startsWith("http")) return hashPart; if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
} }
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const filename = urlObj.pathname.split("/").pop(); const pathname = urlObj.pathname;
if (filename?.length) return filename; const pathParts = pathname.split("/");
const filename = pathParts.at(-1);
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch { } catch {
// Invalid URL // Invalid URL
} }
@@ -55,7 +69,35 @@ export class DownloadManager {
} }
private static sanitizeFilename(filename: string): string { private static sanitizeFilename(filename: string): string {
return filename.replace(/[<>:"/\\|?*]/g, "_"); return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
}
private static resolveFilename(
resumingFilename: string | undefined,
originalUrl: string,
downloadUrl: string
): string | undefined {
if (resumingFilename) return resumingFilename;
const extracted =
this.extractFilename(originalUrl, downloadUrl) ||
this.extractFilename(downloadUrl);
return extracted ? this.sanitizeFilename(extracted) : undefined;
}
private static buildDownloadOptions(
url: string,
savePath: string,
filename: string | undefined,
headers?: Record<string, string>
) {
return {
url,
savePath,
filename,
headers,
};
} }
private static createDownloadPayload( private static createDownloadPayload(
@@ -64,13 +106,19 @@ export class DownloadManager {
downloadId: string, downloadId: string,
savePath: string savePath: string
) { ) {
const filename = this.extractFilename(directUrl, originalUrl); const filename =
this.extractFilename(originalUrl, directUrl) ||
this.extractFilename(directUrl);
const sanitizedFilename = filename const sanitizedFilename = filename
? this.sanitizeFilename(filename) ? this.sanitizeFilename(filename)
: undefined; : undefined;
if (sanitizedFilename) { if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`); logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
);
} }
return { return {
@@ -83,6 +131,19 @@ export class DownloadManager {
}; };
} }
private static async shouldUseJsDownloader(): Promise<boolean> {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
// Default to true - native HTTP downloader is enabled by default (opt-out)
return userPreferences?.useNativeHttpDownloader ?? true;
}
private static isHttpDownloader(downloader: Downloader): boolean {
return downloader !== Downloader.Torrent;
}
public static async startRPC( public static async startRPC(
download?: Download, download?: Download,
downloadsToSeed?: Download[] downloadsToSeed?: Download[]
@@ -107,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"
); );
@@ -156,17 +297,23 @@ 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([ const [download, game] = await Promise.all([
downloadsSublevel.get(gameId), downloadsSublevel.get(gameId),
gamesSublevel.get(gameId), gamesSublevel.get(gameId),
@@ -174,28 +321,65 @@ export class DownloadManager {
if (!download || !game) return; if (!download || !game) return;
this.sendProgressUpdate(progress, status, game);
if (progress === 1) {
await this.handleDownloadCompletion(download, game, gameId);
}
}
private static sendProgressUpdate(
progress: number,
status: DownloadProgress,
game: any
) {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
structuredClone({ ...status, game })
);
}
}
private static async handleDownloadCompletion(
download: Download,
game: any,
gameId: string
) {
publishDownloadCompleteNotification(game);
const userPreferences = await db.get<string, UserPreferences | null>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ valueEncoding: "json" } { valueEncoding: "json" }
); );
if (WindowManager.mainWindow && download) { await this.updateDownloadStatus(
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); download,
WindowManager.mainWindow.webContents.send( gameId,
"on-download-progress", userPreferences?.seedAfterDownloadComplete
JSON.parse(JSON.stringify({ ...status, game }))
); );
if (download.automaticallyExtract) {
this.handleExtraction(download, game);
} else {
// For downloads without extraction (e.g., torrents with ready-to-play files),
// search for executable in the download folder
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
gameFilesManager.searchAndBindExecutable();
} }
await this.processNextQueuedDownload();
}
private static async updateDownloadStatus(
download: Download,
gameId: string,
shouldSeed?: boolean
) {
const shouldExtract = download.automaticallyExtract; const shouldExtract = download.automaticallyExtract;
if (progress === 1 && download) { if (shouldSeed && download.downloader === Downloader.Torrent) {
publishDownloadCompleteNotification(game);
if (
userPreferences?.seedAfterDownloadComplete &&
download.downloader === Downloader.Torrent
) {
await downloadsSublevel.put(gameId, { await downloadsSublevel.put(gameId, {
...download, ...download,
status: "seeding", status: "seeding",
@@ -211,15 +395,12 @@ export class DownloadManager {
queued: false, queued: false,
extracting: shouldExtract, extracting: shouldExtract,
}); });
this.cancelDownload(gameId); this.cancelDownload(gameId);
} }
}
if (shouldExtract) { private static handleExtraction(download: Download, game: any) {
const gameFilesManager = new GameFilesManager( const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
game.shop,
game.objectId
);
if ( if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) => FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
@@ -227,15 +408,16 @@ export class DownloadManager {
) )
) { ) {
gameFilesManager.extractDownloadedFile(); gameFilesManager.extractDownloadedFile();
} else { } else if (download.folderName) {
gameFilesManager gameFilesManager
.extractFilesInDirectory( .extractFilesInDirectory(
path.join(download.downloadPath, download.folderName!) path.join(download.downloadPath, download.folderName)
) )
.then(() => gameFilesManager.setExtractionComplete()); .then(() => gameFilesManager.setExtractionComplete());
} }
} }
private static async processNextQueuedDownload() {
const downloads = await downloadsSublevel const downloads = await downloadsSublevel
.values() .values()
.all() .all()
@@ -253,8 +435,8 @@ export class DownloadManager {
this.resumeDownload(nextItemOnQueue); this.resumeDownload(nextItemOnQueue);
} else { } else {
this.downloadingGameId = null; this.downloadingGameId = null;
} this.usingJsDownloader = false;
} this.jsDownloader = null;
} }
} }
@@ -294,12 +476,17 @@ export class DownloadManager {
} }
static async pauseDownload(downloadKey = this.downloadingGameId) { static async pauseDownload(downloadKey = this.downloadingGameId) {
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Pausing JS download");
this.jsDownloader.pauseDownload();
} else {
await PythonRPC.rpc await PythonRPC.rpc
.post("/action", { .post("/action", {
action: "pause", action: "pause",
game_id: downloadKey, game_id: downloadKey,
} as PauseDownloadPayload) } as PauseDownloadPayload)
.catch(() => {}); .catch(() => {});
}
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
@@ -312,14 +499,23 @@ export class DownloadManager {
} }
static async cancelDownload(downloadKey = this.downloadingGameId) { static async cancelDownload(downloadKey = this.downloadingGameId) {
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Cancelling JS download");
this.jsDownloader.cancelDownload();
this.jsDownloader = null;
this.usingJsDownloader = false;
} else if (!this.isPreparingDownload) {
await PythonRPC.rpc await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey }) .post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err)); .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;
} }
} }
@@ -339,6 +535,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);
@@ -370,15 +801,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 {
@@ -484,12 +906,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 useJsDownloader = await this.shouldUseJsDownloader();
const isHttp = this.isHttpDownloader(download.downloader);
const downloadId = levelKeys.game(download.shop, download.objectId);
if (useJsDownloader && isHttp) {
logger.log("[DownloadManager] Using JS HTTP downloader");
// Set preparing state immediately so UI knows download is starting
this.downloadingGameId = downloadId;
this.isPreparingDownload = true;
this.usingJsDownloader = true;
try {
const options = await this.getJsDownloadOptions(download);
if (!options) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw new Error("Failed to get download options for JS downloader");
}
this.jsDownloader = new JsHttpDownloader();
this.isPreparingDownload = false;
this.jsDownloader.startDownload(options).catch((err) => {
logger.error("[DownloadManager] JS download error:", err);
this.usingJsDownloader = false;
this.jsDownloader = null;
});
} catch (err) {
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.downloadingGameId = null;
throw err;
}
} else {
logger.log("[DownloadManager] Using Python RPC downloader");
const payload = await this.getDownloadPayload(download); const payload = await this.getDownloadPayload(download);
await PythonRPC.rpc.post("/action", payload); await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId); 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

@@ -0,0 +1,13 @@
import { gameExecutables } from "./process-watcher";
export class GameExecutables {
static getExecutablesForGame(objectId: string): string[] | null {
const executables = gameExecutables[objectId];
if (!executables || executables.length === 0) {
return null;
}
return executables.map((exe) => exe.exe);
}
}

View File

@@ -7,6 +7,7 @@ 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";
import { GameExecutables } from "./game-executables";
const PROGRESS_THROTTLE_MS = 1000; const PROGRESS_THROTTLE_MS = 1000;
@@ -151,6 +152,100 @@ export class GameFilesManager {
if (publishNotification && game) { if (publishNotification && game) {
publishExtractionCompleteNotification(game); publishExtractionCompleteNotification(game);
} }
await this.searchAndBindExecutable();
}
async searchAndBindExecutable(): Promise<void> {
try {
const [download, game] = await Promise.all([
downloadsSublevel.get(this.gameKey),
gamesSublevel.get(this.gameKey),
]);
if (!download || !game || game.executablePath) {
return;
}
const executableNames = GameExecutables.getExecutablesForGame(
this.objectId
);
if (!executableNames || executableNames.length === 0) {
return;
}
if (!download.folderName) {
return;
}
const gameFolderPath = path.join(
download.downloadPath,
download.folderName
);
if (!fs.existsSync(gameFolderPath)) {
return;
}
const foundExePath = await this.findExecutableInFolder(
gameFolderPath,
executableNames
);
if (foundExePath) {
logger.info(
`[GameFilesManager] Auto-detected executable for ${this.objectId}: ${foundExePath}`
);
await gamesSublevel.put(this.gameKey, {
...game,
executablePath: foundExePath,
});
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
}
} catch (err) {
logger.error(
`[GameFilesManager] Error searching for executable: ${this.objectId}`,
err
);
}
}
private async findExecutableInFolder(
folderPath: string,
executableNames: string[]
): Promise<string | null> {
const normalizedNames = new Set(
executableNames.map((name) => name.toLowerCase())
);
try {
const entries = await fs.promises.readdir(folderPath, {
withFileTypes: true,
recursive: true,
});
for (const entry of entries) {
if (!entry.isFile()) continue;
const fileName = entry.name.toLowerCase();
if (normalizedNames.has(fileName)) {
const parentPath =
"parentPath" in entry
? entry.parentPath
: (entry as unknown as { path?: string }).path || folderPath;
return path.join(parentPath, entry.name);
}
}
} catch {
// Silently fail if folder cannot be read
}
return null;
} }
async extractDownloadedFile() { async extractDownloadedFile() {

View File

@@ -1,4 +1,6 @@
import axios from "axios"; import axios from "axios";
import http from "node:http";
import https from "node:https";
import { import {
HOSTER_USER_AGENT, HOSTER_USER_AGENT,
extractHosterFilename, extractHosterFilename,
@@ -28,6 +30,12 @@ export class BuzzheavierApi {
await axios.get(baseUrl, { await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT }, headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000, timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
}); });
const downloadUrl = `${baseUrl}/download`; const downloadUrl = `${baseUrl}/download`;
@@ -43,6 +51,12 @@ export class BuzzheavierApi {
validateStatus: (status) => validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302, status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000, timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
}); });
const hxRedirect = headResponse.headers["hx-redirect"]; const hxRedirect = headResponse.headers["hx-redirect"];

View File

@@ -1,7 +1,8 @@
export * from "./gofile"; export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes"; export * from "./datanodes";
export * from "./mediafire"; export * from "./mediafire";
export * from "./pixeldrain"; export * from "./pixeldrain";
export * from "./buzzheavier"; export * from "./buzzheavier";
export * from "./fuckingfast"; export * from "./fuckingfast";
export * from "./vikingfile";
export * from "./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

@@ -10,6 +10,7 @@ export * from "./ludusavi";
export * from "./cloud-sync"; export * from "./cloud-sync";
export * from "./7zip"; export * from "./7zip";
export * from "./game-files-manager"; export * from "./game-files-manager";
export * from "./game-executables";
export * from "./common-redist-manager"; export * from "./common-redist-manager";
export * from "./aria2"; export * from "./aria2";
export * from "./ws"; export * from "./ws";

View File

@@ -69,7 +69,7 @@ const getGameExecutables = async () => {
return gameExecutables; return gameExecutables;
}; };
const gameExecutables = await getGameExecutables(); export const gameExecutables = await getGameExecutables();
const findGamePathByProcess = async ( const findGamePathByProcess = async (
processMap: Map<string, Set<string>>, processMap: Map<string, Set<string>>,

View File

@@ -1,4 +1,6 @@
import axios from "axios"; import axios from "axios";
import http from "node:http";
import getPort, { portNumbers } from "get-port";
import cp from "node:child_process"; import cp from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
@@ -26,11 +28,20 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
win32: "hydra-python-rpc.exe", win32: "hydra-python-rpc.exe",
}; };
const RPC_PORT_RANGE_START = 8080;
const RPC_PORT_RANGE_END = 9000;
const DEFAULT_RPC_PORT = 8084;
const HEALTH_CHECK_INTERVAL_MS = 100;
const HEALTH_CHECK_TIMEOUT_MS = 10000;
export class PythonRPC { export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881"; public static readonly BITTORRENT_PORT = "5881";
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:${DEFAULT_RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
}); });
private static pythonProcess: cp.ChildProcess | null = null; private static pythonProcess: cp.ChildProcess | null = null;
@@ -58,15 +69,46 @@ export class PythonRPC {
return newPassword; return newPassword;
} }
private static async waitForHealthCheck(): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
try {
const response = await this.rpc.get("/healthcheck", { timeout: 1000 });
if (response.status === 200) {
pythonRpcLogger.log("RPC health check passed");
return;
}
} catch {
// Server not ready yet, continue polling
}
await new Promise((resolve) =>
setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS)
);
}
throw new Error("RPC health check timed out");
}
public static async spawn( public static async spawn(
initialDownload?: GamePayload, initialDownload?: GamePayload,
initialSeeding?: GamePayload[] initialSeeding?: GamePayload[]
) { ) {
const rpcPassword = await this.getRPCPassword(); const rpcPassword = await this.getRPCPassword();
const port = await getPort({
port: [
DEFAULT_RPC_PORT,
...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END),
],
});
this.rpc.defaults.baseURL = `http://localhost:${port}`;
pythonRpcLogger.log(`Using RPC port: ${port}`);
const commonArgs = [ const commonArgs = [
this.BITTORRENT_PORT, this.BITTORRENT_PORT,
this.RPC_PORT, String(port),
rpcPassword, rpcPassword,
initialDownload ? JSON.stringify(initialDownload) : "", initialDownload ? JSON.stringify(initialDownload) : "",
initialSeeding ? JSON.stringify(initialSeeding) : "", initialSeeding ? JSON.stringify(initialSeeding) : "",
@@ -87,6 +129,7 @@ export class PythonRPC {
); );
app.quit(); app.quit();
return;
} }
const childProcess = cp.spawn(binaryPath, commonArgs, { const childProcess = cp.spawn(binaryPath, commonArgs, {
@@ -95,7 +138,6 @@ export class PythonRPC {
}); });
this.logStderr(childProcess.stderr); this.logStderr(childProcess.stderr);
this.pythonProcess = childProcess; this.pythonProcess = childProcess;
} else { } else {
const scriptPath = path.join( const scriptPath = path.join(
@@ -111,11 +153,23 @@ export class PythonRPC {
}); });
this.logStderr(childProcess.stderr); this.logStderr(childProcess.stderr);
this.pythonProcess = childProcess; this.pythonProcess = childProcess;
} }
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword; this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
try {
await this.waitForHealthCheck();
pythonRpcLogger.log(`Python RPC started successfully on port ${port}`);
} catch (err) {
pythonRpcLogger.log(`Failed to start Python RPC: ${err}`);
dialog.showErrorBox(
"RPC Error",
`Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.`
);
this.kill();
throw err;
}
} }
public static kill() { public static kill() {

View File

@@ -138,12 +138,21 @@ export class WindowManager {
(details, callback) => { (details, callback) => {
if ( if (
details.webContentsId !== this.mainWindow?.webContents.id || details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("chatwoot") || details.url.includes("chatwoot")
details.url.includes("workwonders")
) { ) {
return callback(details); return callback(details);
} }
if (details.url.includes("workwonders")) {
return callback({
...details,
requestHeaders: {
Origin: "https://workwonders.app",
...details.requestHeaders,
},
});
}
const userAgent = new UserAgent(); const userAgent = new UserAgent();
callback({ callback({

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

@@ -206,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) => openGameInstallerPath: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstallerPath", shop, objectId), ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
openGameExecutablePath: (shop: GameShop, objectId: string) => openGameExecutablePath: (shop: GameShop, objectId: string) =>
@@ -239,6 +241,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime), ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
extractGameDownload: (shop: GameShop, objectId: string) => extractGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("extractGameDownload", shop, objectId), ipcRenderer.invoke("extractGameDownload", shop, objectId),
scanInstalledGames: () => ipcRenderer.invoke("scanInstalledGames"),
getDefaultWinePrefixSelectionPath: () => getDefaultWinePrefixSelectionPath: () =>
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"), ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
createSteamShortcut: (shop: GameShop, objectId: string) => createSteamShortcut: (shop: GameShop, objectId: string) =>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } 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 { WorkWonders } from "workwonders-sdk";
import { import {
useAppDispatch, useAppDispatch,
useAppSelector, useAppSelector,
@@ -52,6 +52,8 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWonders | null>(null);
const { const {
hasActiveSubscription, hasActiveSubscription,
fetchUserDetails, fetchUserDetails,
@@ -114,7 +116,33 @@ 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 WorkWonders();
await workwondersRef.current.init({
organization: "hydra",
token,
locale: parsedLocale,
});
await workwondersRef.current.changelog.initChangelogWidget();
workwondersRef.current.changelog.initChangelogWidgetMini();
if (token) {
workwondersRef.current.feedback.initFeedbackWidget();
}
},
[workwondersRef]
);
const setupExternalResources = useCallback(async () => {
const cachedUserDetails = window.localStorage.getItem("userDetails"); const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) { if (cachedUserDetails) {
@@ -125,21 +153,26 @@ 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);
}
})
.finally(() => {
if (document.getElementById("external-resources")) return;
if (userDetails) {
updateUserDetails(userDetails);
}
setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
if (!document.getElementById("external-resources")) {
const $script = document.createElement("script"); const $script = document.createElement("script");
$script.id = "external-resources"; $script.id = "external-resources";
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`; $script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script); document.head.appendChild($script);
}); }
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
useEffect(() => {
setupExternalResources();
}, [setupExternalResources]);
const onSignIn = useCallback(() => { const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
@@ -203,6 +236,7 @@ export function App() {
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(() => {

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

@@ -61,10 +61,26 @@
cursor: pointer; cursor: pointer;
transition: all ease 0.2s; transition: all ease 0.2s;
padding: globals.$spacing-unit; padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
&:hover { &:hover {
color: #dadbe1; color: #dadbe1;
} }
&--scanning svg {
animation: spin 2s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
&__section { &__section {

View File

@@ -1,7 +1,13 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import {
ArrowLeftIcon,
SearchIcon,
SyncIcon,
XIcon,
} from "@primer/octicons-react";
import { Tooltip } from "react-tooltip";
import { import {
useAppDispatch, useAppDispatch,
@@ -12,6 +18,7 @@ import {
import "./header.scss"; import "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { ScanGamesModal } from "./scan-games-modal";
import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames"; import cn from "classnames";
import { SearchDropdown } from "@renderer/components"; import { SearchDropdown } from "@renderer/components";
@@ -29,9 +36,11 @@ const pathTitle: Record<string, string> = {
export function Header() { export function Header() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null); const searchContainerRef = useRef<HTMLDivElement>(null);
const scanButtonTooltipId = useId();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { headerTitle, draggingDisabled } = useAppSelector( const { headerTitle, draggingDisabled } = useAppSelector(
(state) => state.window (state) => state.window
@@ -61,6 +70,12 @@ export function Header() {
x: 0, x: 0,
y: 0, y: 0,
}); });
const [showScanModal, setShowScanModal] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [scanResult, setScanResult] = useState<{
foundGames: { title: string; executablePath: string }[];
total: number;
} | null>(null);
const { t } = useTranslation("header"); const { t } = useTranslation("header");
@@ -224,20 +239,24 @@ export function Header() {
setActiveIndex(-1); setActiveIndex(-1);
}; };
useEffect(() => { const handleStartScan = async () => {
const prevPath = sessionStorage.getItem("prevPath"); if (isScanning) return;
const currentPath = location.pathname;
if ( setIsScanning(true);
prevPath?.startsWith("/catalogue") && setScanResult(null);
!currentPath.startsWith("/catalogue") && setShowScanModal(false);
catalogueSearchValue
) { try {
dispatch(setFilters({ title: "" })); const result = await window.electron.scanInstalledGames();
setScanResult(result);
} finally {
setIsScanning(false);
} }
};
sessionStorage.setItem("prevPath", currentPath); const handleClearScanResult = () => {
}, [location.pathname, catalogueSearchValue, dispatch]); setScanResult(null);
};
useEffect(() => { useEffect(() => {
if (!isDropdownVisible) return; if (!isDropdownVisible) return;
@@ -250,6 +269,14 @@ export function Header() {
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, [isDropdownVisible]); }, [isDropdownVisible]);
useEffect(() => {
if (searchParams.get("openScanModal") === "true") {
setShowScanModal(true);
searchParams.delete("openScanModal");
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, setSearchParams]);
return ( return (
<> <>
<header <header
@@ -280,6 +307,21 @@ export function Header() {
</section> </section>
<section className="header__section"> <section className="header__section">
{isOnLibraryPage && window.electron.platform === "win32" && (
<button
type="button"
className={cn("header__action-button", {
"header__action-button--scanning": isScanning,
})}
onClick={() => setShowScanModal(true)}
data-tooltip-id={scanButtonTooltipId}
data-tooltip-content={t("scan_games_tooltip")}
data-tooltip-place="bottom"
>
<SyncIcon size={16} />
</button>
)}
<div <div
ref={searchContainerRef} ref={searchContainerRef}
className={cn("header__search", { className={cn("header__search", {
@@ -319,6 +361,11 @@ export function Header() {
</div> </div>
</section> </section>
</header> </header>
{isOnLibraryPage && window.electron.platform === "win32" && (
<Tooltip id={scanButtonTooltipId} style={{ zIndex: 1 }} />
)}
<AutoUpdateSubHeader /> <AutoUpdateSubHeader />
<SearchDropdown <SearchDropdown
@@ -342,6 +389,15 @@ export function Header() {
currentQuery={searchValue} currentQuery={searchValue}
searchContainerRef={searchContainerRef} searchContainerRef={searchContainerRef}
/> />
<ScanGamesModal
visible={showScanModal}
onClose={() => setShowScanModal(false)}
isScanning={isScanning}
scanResult={scanResult}
onStartScan={handleStartScan}
onClearResult={handleClearScanResult}
/>
</> </>
); );
} }

View File

@@ -0,0 +1,107 @@
@use "../../scss/globals.scss";
.scan-games-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
min-width: 400px;
&__description {
color: globals.$muted-color;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
&__results {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__result {
color: globals.$body-color;
font-size: 14px;
margin: 0;
}
&__no-results {
color: globals.$muted-color;
font-size: 14px;
margin: 0;
text-align: center;
padding: calc(globals.$spacing-unit * 2) 0;
}
&__scanning {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 3) 0;
}
&__spinner {
color: globals.$muted-color;
animation: spin 2s linear infinite;
}
&__scanning-text {
color: globals.$muted-color;
font-size: 14px;
margin: 0;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&__games-list {
list-style: none;
margin: 0;
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
background-color: globals.$dark-background-color;
border-radius: 4px;
padding: calc(globals.$spacing-unit * 2);
}
&__game-item {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: globals.$spacing-unit;
border-bottom: 1px solid globals.$border-color;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
&__game-title {
color: globals.$body-color;
font-size: 14px;
font-weight: 500;
}
&__game-path {
color: globals.$muted-color;
font-size: 12px;
word-break: break-all;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -0,0 +1,126 @@
import { useTranslation } from "react-i18next";
import { SyncIcon } from "@primer/octicons-react";
import { Button, Modal } from "@renderer/components";
import "./scan-games-modal.scss";
interface FoundGame {
title: string;
executablePath: string;
}
interface ScanResult {
foundGames: FoundGame[];
total: number;
}
export interface ScanGamesModalProps {
visible: boolean;
onClose: () => void;
isScanning: boolean;
scanResult: ScanResult | null;
onStartScan: () => void;
onClearResult: () => void;
}
export function ScanGamesModal({
visible,
onClose,
isScanning,
scanResult,
onStartScan,
onClearResult,
}: Readonly<ScanGamesModalProps>) {
const { t } = useTranslation("header");
const handleClose = () => {
onClose();
};
const handleStartScan = () => {
onStartScan();
};
const handleScanAgain = () => {
onClearResult();
onStartScan();
};
return (
<Modal
visible={visible}
title={t("scan_games_title")}
onClose={handleClose}
clickOutsideToClose={!isScanning}
>
<div className="scan-games-modal">
{!scanResult && !isScanning && (
<p className="scan-games-modal__description">
{t("scan_games_description")}
</p>
)}
{isScanning && !scanResult && (
<div className="scan-games-modal__scanning">
<SyncIcon size={24} className="scan-games-modal__spinner" />
<p className="scan-games-modal__scanning-text">
{t("scan_games_in_progress")}
</p>
</div>
)}
{scanResult && (
<div className="scan-games-modal__results">
{scanResult.foundGames.length > 0 ? (
<>
<p className="scan-games-modal__result">
{t("scan_games_result", {
found: scanResult.foundGames.length,
total: scanResult.total,
})}
</p>
<ul className="scan-games-modal__games-list">
{scanResult.foundGames.map((game) => (
<li
key={game.executablePath}
className="scan-games-modal__game-item"
>
<span className="scan-games-modal__game-title">
{game.title}
</span>
<span className="scan-games-modal__game-path">
{game.executablePath}
</span>
</li>
))}
</ul>
</>
) : (
<p className="scan-games-modal__no-results">
{t("scan_games_no_results")}
</p>
)}
</div>
)}
<div className="scan-games-modal__actions">
<Button theme="outline" onClick={handleClose}>
{scanResult ? t("scan_games_close") : t("scan_games_cancel")}
</Button>
{!scanResult && (
<Button onClick={handleStartScan} disabled={isScanning}>
{t("scan_games_start")}
</Button>
)}
{scanResult && (
<Button onClick={handleScanAgain}>
{t("scan_games_scan_again")}
</Button>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { BellIcon } from "@primer/octicons-react"; import { BellIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
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";
@@ -20,51 +20,60 @@ export function SidebarProfile() {
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0); const [notificationCount, setNotificationCount] = useState(0);
const apiNotificationCountRef = useRef(0);
const hasFetchedInitialCount = useRef(false);
const fetchNotificationCount = useCallback(async () => { const fetchLocalNotificationCount = useCallback(async () => {
try { try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount(); const localCount = await window.electron.getLocalNotificationsCount();
setNotificationCount(localCount + apiNotificationCountRef.current);
} catch (error) {
logger.error("Failed to fetch local notification count", error);
}
}, []);
// Fetch API notification count only if logged in const fetchApiNotificationCount = useCallback(async () => {
let apiCount = 0;
if (userDetails) {
try { try {
const response = const response =
await window.electron.hydraApi.get<NotificationCountResponse>( await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count", "/profile/notifications/count",
{ needsAuth: true } { needsAuth: true }
); );
apiCount = response.count; apiNotificationCountRef.current = response.count;
} catch { } catch {
// Ignore API errors // Ignore API errors
} }
} fetchLocalNotificationCount();
}, [fetchLocalNotificationCount]);
setNotificationCount(localCount + apiCount);
} catch (error) {
logger.error("Failed to fetch notification count", error);
}
}, [userDetails]);
// Initial fetch on mount (only once)
useEffect(() => { useEffect(() => {
fetchNotificationCount(); fetchLocalNotificationCount();
}, [fetchLocalNotificationCount]);
const interval = setInterval(fetchNotificationCount, 60000); // Fetch API count when user logs in (only if not already fetched)
return () => clearInterval(interval); useEffect(() => {
}, [fetchNotificationCount]); if (userDetails && !hasFetchedInitialCount.current) {
hasFetchedInitialCount.current = true;
fetchApiNotificationCount();
} else if (!userDetails) {
hasFetchedInitialCount.current = false;
apiNotificationCountRef.current = 0;
fetchLocalNotificationCount();
}
}, [userDetails, fetchApiNotificationCount, fetchLocalNotificationCount]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => { const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount(); fetchLocalNotificationCount();
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [fetchNotificationCount]); }, [fetchLocalNotificationCount]);
useEffect(() => { useEffect(() => {
const handleNotificationsChange = () => { const handleNotificationsChange = () => {
fetchNotificationCount(); fetchLocalNotificationCount();
}; };
window.addEventListener("notificationsChanged", handleNotificationsChange); window.addEventListener("notificationsChanged", handleNotificationsChange);
@@ -74,15 +83,18 @@ export function SidebarProfile() {
handleNotificationsChange handleNotificationsChange
); );
}; };
}, [fetchNotificationCount]); }, [fetchLocalNotificationCount]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => { const unsubscribe = window.electron.onSyncNotificationCount(
fetchNotificationCount(); (notification) => {
}); apiNotificationCountRef.current = notification.notificationCount;
fetchLocalNotificationCount();
}
);
return () => unsubscribe(); return () => unsubscribe();
}, [fetchNotificationCount]); }, [fetchLocalNotificationCount]);
const handleProfileClick = () => { const handleProfileClick = () => {
if (userDetails === null) { if (userDetails === null) {

View File

@@ -1,19 +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.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast", [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

@@ -225,6 +225,16 @@ export function GameDetailsContextProvider({
}; };
}, [game?.id, isGameRunning, updateGame]); }, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
updateGame();
});
return () => {
unsubscribe();
};
}, [updateGame]);
useEffect(() => { useEffect(() => {
const handler = (ev: Event) => { const handler = (ev: Event) => {
try { try {

View File

@@ -167,6 +167,10 @@ declare global {
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>; refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
getGameInstallerActionType: (
shop: GameShop,
objectId: string
) => Promise<"install" | "open-folder">;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
openGame: ( openGame: (
@@ -207,6 +211,10 @@ declare global {
minimized: boolean; minimized: boolean;
}) => Promise<void>; }) => Promise<void>;
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>; extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
scanInstalledGames: () => Promise<{
foundGames: { title: string; executablePath: string }[];
total: number;
}>;
onExtractionComplete: ( onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import resources from "@locales";
import { logger } from "./logger"; import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies"; import { addCookieInterceptor } from "./cookies";
import * as Sentry from "@sentry/react";
import { levelDBService } from "./services/leveldb.service"; import { levelDBService } from "./services/leveldb.service";
import Catalogue from "./pages/catalogue/catalogue"; import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home"; import Home from "./pages/home/home";
@@ -36,6 +37,18 @@ import { AchievementNotification } from "./pages/achievements/notification/achie
console.log = logger.log; console.log = logger.log;
Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
tracesSampleRate: 0.5,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
release: "hydra-launcher@" + (await window.electron.getVersion()),
});
const isStaging = await window.electron.isStaging(); const isStaging = await window.electron.isStaging();
addCookieInterceptor(isStaging); addCookieInterceptor(isStaging);

View File

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

View File

@@ -1,6 +1,6 @@
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components"; import { Badge, Button, ConfirmationModal } from "@renderer/components";
import { import {
formatDownloadProgress, formatDownloadProgress,
buildGameDetailsPath, buildGameDetailsPath,
@@ -32,12 +32,12 @@ import {
FileDirectoryIcon, FileDirectoryIcon,
LinkIcon, LinkIcon,
PlayIcon, PlayIcon,
ThreeBarsIcon,
TrashIcon, TrashIcon,
UnlinkIcon, UnlinkIcon,
XCircleIcon, XCircleIcon,
GraphIcon, GraphIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js"; import { average } from "color.js";
interface AnimatedPercentageProps { interface AnimatedPercentageProps {
@@ -219,7 +219,7 @@ interface HeroDownloadViewProps {
calculateETA: () => string; calculateETA: () => string;
pauseDownload: (shop: GameShop, objectId: string) => void; pauseDownload: (shop: GameShop, objectId: string) => void;
resumeDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void;
cancelDownload: (shop: GameShop, objectId: string) => void; onCancelClick: (shop: GameShop, objectId: string) => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -238,7 +238,7 @@ function HeroDownloadView({
calculateETA, calculateETA,
pauseDownload, pauseDownload,
resumeDownload, resumeDownload,
cancelDownload, onCancelClick,
t, t,
}: Readonly<HeroDownloadViewProps>) { }: Readonly<HeroDownloadViewProps>) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -353,7 +353,7 @@ function HeroDownloadView({
)} )}
<button <button
type="button" type="button"
onClick={() => cancelDownload(game.shop, game.objectId)} onClick={() => onCancelClick(game.shop, game.objectId)}
className="download-group__glass-btn" className="download-group__glass-btn"
> >
<XCircleIcon size={14} /> <XCircleIcon size={14} />
@@ -452,6 +452,7 @@ export function DownloadGroup({
seedingStatus, seedingStatus,
}: Readonly<DownloadGroupProps>) { }: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
const navigate = useNavigate(); const navigate = useNavigate();
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
@@ -523,6 +524,16 @@ export function DownloadGroup({
const [optimisticallyResumed, setOptimisticallyResumed] = useState< const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [cancelModalVisible, setCancelModalVisible] = useState(false);
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
null
);
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
string | null
>(null);
const [gameActionTypes, setGameActionTypes] = useState<
Record<string, "install" | "open-folder">
>({});
const extractDominantColor = useCallback( const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => { async (imageUrl: string, gameId: string) => {
@@ -613,11 +624,18 @@ export function DownloadGroup({
const download = game.download!; const download = game.download!;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
if (download.fileSize != null) return formatBytes(download.fileSize); // Check lastPacket first for most up-to-date size during active downloads
if (
if (lastPacket?.download.fileSize && isGameDownloading) isGameDownloading &&
lastPacket?.download.fileSize &&
lastPacket.download.fileSize > 0
)
return formatBytes(lastPacket.download.fileSize); return formatBytes(lastPacket.download.fileSize);
// Then check the stored download size (must be > 0 to be valid)
if (download.fileSize != null && download.fileSize > 0)
return formatBytes(download.fileSize);
return "N/A"; return "N/A";
}; };
@@ -651,6 +669,27 @@ export function DownloadGroup({
[updateLibrary] [updateLibrary]
); );
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
setGameToCancelShop(shop);
setGameToCancelObjectId(objectId);
setCancelModalVisible(true);
}, []);
const handleConfirmCancel = useCallback(async () => {
if (gameToCancelShop && gameToCancelObjectId) {
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
}
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
const handleCancelModalClose = useCallback(() => {
setCancelModalVisible(false);
setGameToCancelShop(null);
setGameToCancelObjectId(null);
}, []);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download; const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id]; const isGameDownloading = isGameDownloadingMap[game.id];
@@ -659,14 +698,6 @@ export function DownloadGroup({
if (game.download?.progress === 1) { if (game.download?.progress === 1) {
const actions = [ const actions = [
{
label: t("install"),
disabled: deleting,
onClick: () => {
openGameInstaller(game.shop, game.objectId);
},
icon: <DownloadIcon />,
},
{ {
label: t("extract"), label: t("extract"),
disabled: game.download.extracting, disabled: game.download.extracting,
@@ -721,7 +752,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -746,7 +777,7 @@ export function DownloadGroup({
{ {
label: t("cancel"), label: t("cancel"),
onClick: () => { onClick: () => {
cancelDownload(game.shop, game.objectId); handleCancelClick(game.shop, game.objectId);
}, },
icon: <XCircleIcon />, icon: <XCircleIcon />,
}, },
@@ -770,6 +801,37 @@ export function DownloadGroup({
] ]
); );
// Fetch action types for completed games
useEffect(() => {
const fetchActionTypes = async () => {
const completedGames = library.filter(
(game) => game.download?.progress === 1
);
const actionTypesPromises = completedGames.map(async (game) => {
try {
const actionType = await window.electron.getGameInstallerActionType(
game.shop,
game.objectId
);
return { gameId: game.id, actionType };
} catch {
return { gameId: game.id, actionType: "open-folder" as const };
}
});
const results = await Promise.all(actionTypesPromises);
const newActionTypes: Record<string, "install" | "open-folder"> = {};
results.forEach(({ gameId, actionType }) => {
newActionTypes[gameId] = actionType;
});
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
};
fetchActionTypes();
}, [library]);
if (!library.length) return null; if (!library.length) return null;
const isDownloadingGroup = title === t("download_in_progress"); const isDownloadingGroup = title === t("download_in_progress");
@@ -804,6 +866,16 @@ export function DownloadGroup({
const dominantColor = dominantColors[game.id] || "#fff"; const dominantColor = dominantColors[game.id] || "#fff";
return ( return (
<>
<ConfirmationModal
visible={cancelModalVisible}
title={t("cancel_download")}
descriptionText={t("cancel_download_description")}
confirmButtonLabel={t("yes_cancel")}
cancelButtonLabel={t("keep_downloading")}
onConfirm={handleConfirmCancel}
onClose={handleCancelModalClose}
/>
<HeroDownloadView <HeroDownloadView
game={game} game={game}
isGameDownloading={isGameDownloading} isGameDownloading={isGameDownloading}
@@ -819,13 +891,24 @@ export function DownloadGroup({
calculateETA={calculateETA} calculateETA={calculateETA}
pauseDownload={pauseDownload} pauseDownload={pauseDownload}
resumeDownload={resumeDownload} resumeDownload={resumeDownload}
cancelDownload={cancelDownload} onCancelClick={handleCancelClick}
t={t} t={t}
/> />
</>
); );
} }
return ( return (
<>
<ConfirmationModal
visible={cancelModalVisible}
title={t("cancel_download")}
descriptionText={t("cancel_download_description")}
confirmButtonLabel={t("yes_cancel")}
cancelButtonLabel={t("keep_downloading")}
onConfirm={handleConfirmCancel}
onClose={handleCancelModalClose}
/>
<div <div
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`} className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
> >
@@ -854,7 +937,9 @@ export function DownloadGroup({
onClick={() => navigate(buildGameDetailsPath(game))} onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button" className="download-group__simple-title-button"
> >
<h3 className="download-group__simple-title">{game.title}</h3> <h3 className="download-group__simple-title">
{game.title}
</h3>
</button> </button>
<div className="download-group__simple-meta"> <div className="download-group__simple-meta">
<div className="download-group__simple-meta-row"> <div className="download-group__simple-meta-row">
@@ -901,16 +986,35 @@ export function DownloadGroup({
)} )}
<div className="download-group__simple-actions"> <div className="download-group__simple-actions">
{game.download?.progress === 1 && ( {game.download?.progress === 1 &&
(() => {
const actionType =
gameActionTypes[game.id] || "open-folder";
const isInstall = actionType === "install";
return (
<Button <Button
theme="primary" theme="primary"
onClick={() => openGameInstaller(game.shop, game.objectId)} onClick={() =>
openGameInstaller(game.shop, game.objectId)
}
disabled={isGameDeleting(game.id)} disabled={isGameDeleting(game.id)}
className="download-group__simple-menu-btn" className="download-group__simple-action-btn"
> >
<PlayIcon size={16} /> {isInstall ? (
</Button> <>
<DownloadIcon size={16} />
{t("install")}
</>
) : (
<>
<Folder size={16} />
{tGameDetails("open_folder")}
</>
)} )}
</Button>
);
})()}
{isQueuedGroup && game.download?.progress !== 1 && ( {isQueuedGroup && game.download?.progress !== 1 && (
<Button <Button
theme="primary" theme="primary"
@@ -926,7 +1030,7 @@ export function DownloadGroup({
theme="outline" theme="outline"
className="download-group__simple-menu-btn" className="download-group__simple-menu-btn"
> >
<ThreeBarsIcon /> <MoreVertical size={16} />
</Button> </Button>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -935,5 +1039,6 @@ export function DownloadGroup({
})} })}
</ul> </ul>
</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,35 +252,146 @@ 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 && const Indicator = option.isAvailable ? motion.span : "span";
!isFeatureEnabled(Feature.Nimbus));
const isDisabled =
!option.canHandle ||
(!option.isAvailable && !option.isAvailableButNotConfigured);
const getAvailabilityIndicator = () => {
if (option.isAvailable) {
return (
<Indicator
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--available download-settings-modal__availability-indicator--pulsating`}
animate={{
scale: [1, 1.1, 1],
opacity: [1, 0.7, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_online")}
/>
);
}
if (option.isAvailableButNotConfigured) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_configured")}
/>
);
}
if (option.canHandle) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--unavailable`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_offline")}
/>
);
}
return ( return (
<Button <span
key={downloader} className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
className="download-settings-modal__downloader-option" data-tooltip-id={tooltipId}
theme={ data-tooltip-content={t("downloader_not_available")}
selectedDownloader === downloader ? "primary" : "outline" />
} );
disabled={shouldDisableButton} };
onClick={() => setSelectedDownloader(downloader)}
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,
}}
> >
{selectedDownloader === downloader && ( <CheckCircleFillIcon
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" /> size={16}
)} className="download-settings-modal__check-icon"
{DOWNLOADER_NAME[downloader]} />
</Button> </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>
<div className="download-settings-modal__downloads-path-field"> <div className="download-settings-modal__downloads-path-field">
<TextField <TextField
@@ -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)
)
} }
> >
{downloadStarting ? (
<>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon /> <DownloadIcon />
{t("download_now")} {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

@@ -40,6 +40,34 @@
gap: calc(globals.$spacing-unit * 1); gap: calc(globals.$spacing-unit * 1);
color: globals.$body-color; color: globals.$body-color;
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
padding-right: calc(globals.$spacing-unit * 4);
position: relative;
}
&__availability-orb {
position: absolute;
top: calc(globals.$spacing-unit * 1.5);
right: calc(globals.$spacing-unit * 1.5);
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--online {
background-color: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
&--partial {
background-color: #eab308;
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
}
&--offline {
background-color: #ef4444;
opacity: 0.7;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
}
} }
&__repack-title { &__repack-title {

View File

@@ -6,6 +6,7 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { Tooltip } from "react-tooltip";
import { import {
Badge, Badge,
@@ -185,6 +186,20 @@ export function RepacksModal({
); );
}, [repacks, hashesInDebrid]); }, [repacks, hashesInDebrid]);
const getRepackAvailabilityStatus = (
repack: GameRepack
): "online" | "partial" | "offline" => {
const unavailableSet = new Set(repack.unavailableUris ?? []);
const availableCount = repack.uris.filter(
(uri) => !unavailableSet.has(uri)
).length;
const unavailableCount = repack.uris.length - availableCount;
if (unavailableCount === 0) return "online";
if (availableCount === 0) return "offline";
return "partial";
};
useEffect(() => { useEffect(() => {
const term = filterTerm.trim().toLowerCase(); const term = filterTerm.trim().toLowerCase();
@@ -363,6 +378,8 @@ export function RepacksModal({
filteredRepacks.map((repack) => { filteredRepacks.map((repack) => {
const isLastDownloadedOption = const isLastDownloadedOption =
checkIfLastDownloadedOption(repack); checkIfLastDownloadedOption(repack);
const availabilityStatus = getRepackAvailabilityStatus(repack);
const tooltipId = `availability-orb-${repack.id}`;
return ( return (
<Button <Button
@@ -371,6 +388,13 @@ export function RepacksModal({
onClick={() => handleRepackClick(repack)} onClick={() => handleRepackClick(repack)}
className="repacks-modal__repack-button" className="repacks-modal__repack-button"
> >
<span
className={`repacks-modal__availability-orb repacks-modal__availability-orb--${availabilityStatus}`}
data-tooltip-id={tooltipId}
data-tooltip-content={t(`source_${availabilityStatus}`)}
/>
<Tooltip id={tooltipId} />
<p className="repacks-modal__repack-title"> <p className="repacks-modal__repack-title">
{repack.title} {repack.title}
{userPreferences?.enableNewDownloadOptionsBadges !== {userPreferences?.enableNewDownloadOptionsBadges !==

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>
{imageError || !coverImage ? (
<div className="library-game-card__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img <img
src={coverImage ?? undefined} src={coverImage}
alt={game.title} alt={game.title}
className="library-game-card__game-image" className="library-game-card__game-image"
loading="lazy" loading="lazy"
onError={() => setImageError(true)}
/> />
)}
</button> </button>
); );
}); });

View File

@@ -58,6 +58,8 @@ export function LocalNotificationItem({
return <SyncIcon size={24} />; return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED": case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />; return <TrophyIcon size={24} />;
case "SCAN_GAMES_COMPLETE":
return <SyncIcon size={24} />;
default: default:
return <DownloadIcon size={24} />; return <DownloadIcon size={24} />;
} }

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "@primer/octicons-react"; import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
@@ -18,6 +18,11 @@ import type {
} from "@types"; } from "@types";
import "./notifications.scss"; import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() { export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page"); const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
@@ -34,12 +39,14 @@ export default function Notifications() {
>([]); >([]);
const [badges, setBadges] = useState<Badge[]>([]); const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set()); const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
total: 0, total: 0,
hasMore: false, hasMore: false,
skip: 0, skip: 0,
}); });
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => { const fetchLocalNotifications = useCallback(async () => {
try { try {
@@ -65,7 +72,11 @@ export default function Notifications() {
}, [i18n.language]); }, [i18n.language]);
const fetchApiNotifications = useCallback( const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => { async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return; if (!userDetails) return;
try { try {
@@ -74,7 +85,7 @@ export default function Notifications() {
await window.electron.hydraApi.get<NotificationsResponse>( await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications", "/profile/notifications",
{ {
params: { filter: "all", take: 20, skip }, params: { filter: filterParam, take: 20, skip },
needsAuth: true, needsAuth: true,
} }
); );
@@ -101,24 +112,24 @@ export default function Notifications() {
[userDetails] [userDetails]
); );
const fetchAllNotifications = useCallback(async () => { const fetchAllNotifications = useCallback(
async (filterParam: NotificationFilter = "all") => {
setIsLoading(true); setIsLoading(true);
await Promise.all([ await Promise.all([
fetchLocalNotifications(), fetchLocalNotifications(),
fetchBadges(), fetchBadges(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(), userDetails
? fetchApiNotifications(0, false, filterParam)
: Promise.resolve(),
]); ]);
setIsLoading(false); setIsLoading(false);
}, [ },
fetchLocalNotifications, [fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
fetchBadges, );
fetchApiNotifications,
userDetails,
]);
useEffect(() => { useEffect(() => {
fetchAllNotifications(); fetchAllNotifications(filter);
}, [fetchAllNotifications]); }, [fetchAllNotifications, filter]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated( const unsubscribe = window.electron.onLocalNotificationCreated(
@@ -130,6 +141,13 @@ export default function Notifications() {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => { const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) => const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -144,23 +162,28 @@ export default function Notifications() {
.filter((n) => n.priority !== 1) .filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const })); .map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map( // Filter local notifications based on current filter
(n) => ({ const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
...n, ...n,
source: "local" as const, source: "local" as const,
}) }));
);
const lowPriority = [...lowPriorityApi, ...localWithSource].sort( const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate sortByDate
); );
return [...highPriority, ...lowPriority]; return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications]); }, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => { const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id)); return mergedNotifications;
}, [mergedNotifications, clearingIds]); }, [mergedNotifications]);
const notifyCountChange = useCallback(() => { const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged")); window.dispatchEvent(new CustomEvent("notificationsChanged"));
@@ -251,42 +274,86 @@ export default function Notifications() {
[showErrorToast, t, notifyCountChange] [showErrorToast, t, notifyCountChange]
); );
const removeNotificationFromState = useCallback(
(notification: MergedNotification) => {
if (notification.source === "api") {
setApiNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
} else {
setLocalNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}
},
[]
);
const removeNotificationWithDelay = useCallback(
(notification: MergedNotification, delayMs: number): Promise<void> => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
removeNotificationFromState(notification);
resolve();
}, delayMs);
clearingTimeoutsRef.current.push(timeout);
});
},
[removeNotificationFromState]
);
const handleClearAll = useCallback(async () => { const handleClearAll = useCallback(async () => {
if (isClearing) return;
try { try {
// Mark all as clearing for animation setIsClearing(true);
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
// Wait for exit animation // Clear any existing timeouts
await new Promise((resolve) => setTimeout(resolve, 300)); clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Clear all API notifications // Snapshot current notifications for staggered removal
if (userDetails && apiNotifications.length > 0) { const notificationsToRemove = [...displayedNotifications];
const totalNotifications = notificationsToRemove.length;
if (totalNotifications === 0) {
setIsClearing(false);
return;
}
// Remove items one by one with staggered delays for visual effect
const removalPromises = notificationsToRemove.map((notification, index) =>
removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS)
);
// Wait for all items to be removed from state
await Promise.all(removalPromises);
// Wait for the last exit animation to complete
await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS));
// Perform actual backend deletions (state is already cleared by staggered removal)
if (userDetails) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, { await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true, needsAuth: true,
}); });
setApiNotifications([]);
} }
// Clear all local notifications
await window.electron.clearAllLocalNotifications(); await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 }); setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange(); notifyCountChange();
showSuccessToast(t("cleared_all")); showSuccessToast(t("cleared_all"));
} catch (error) { } catch (error) {
logger.error("Failed to clear all notifications", error); logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear")); showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
} }
}, [ }, [
apiNotifications, displayedNotifications,
localNotifications, isClearing,
removeNotificationWithDelay,
userDetails, userDetails,
showSuccessToast, showSuccessToast,
showErrorToast, showErrorToast,
@@ -296,9 +363,19 @@ export default function Notifications() {
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) { if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true); fetchApiNotifications(pagination.skip, true, filter);
} }
}, [pagination, isLoading, fetchApiNotifications]); }, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
const handleAcceptFriendRequest = useCallback(() => { const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted")); showSuccessToast(t("friend_request_accepted"));
@@ -317,10 +394,13 @@ export default function Notifications() {
return ( return (
<motion.div <motion.div
key={key} key={key}
layout
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }} exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{notification.source === "local" ? ( {notification.source === "local" ? (
@@ -343,8 +423,57 @@ export default function Notifications() {
); );
}; };
const unreadCount = useMemo(() => {
const apiUnread = apiNotifications.filter((n) => !n.isRead).length;
const localUnread = localNotifications.filter((n) => !n.isRead).length;
return apiUnread + localUnread;
}, [apiNotifications, localNotifications]);
const renderFilterTabs = () => (
<div className="notifications__filter-tabs">
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "all" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("all")}
>
{t("filter_all")}
</button>
{filter === "all" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "unread" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("unread")}
>
{t("filter_unread")}
{unreadCount > 0 && (
<span className="notifications__tab-badge">{unreadCount}</span>
)}
</button>
{filter === "unread" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
</div>
);
const hasNoNotifications = mergedNotifications.length === 0;
const shouldDisableActions = isClearing || hasNoNotifications;
const renderContent = () => { const renderContent = () => {
if (isLoading && mergedNotifications.length === 0) { if (isLoading && hasNoNotifications) {
return ( return (
<div className="notifications__loading"> <div className="notifications__loading">
<span>{t("loading")}</span> <span>{t("loading")}</span>
@@ -352,36 +481,61 @@ export default function Notifications() {
); );
} }
if (mergedNotifications.length === 0) {
return ( return (
<div className="notifications">
<div className="notifications__header">
{renderFilterTabs()}
<div className="notifications__actions">
<Button
theme="outline"
onClick={handleMarkAllAsRead}
disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
</div>
{/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="wait">
<motion.div
key={filter}
className="notifications__content-wrapper"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{hasNoNotifications && !isClearing ? (
<div className="notifications__empty"> <div className="notifications__empty">
<div className="notifications__icon-container"> <div className="notifications__icon-container">
<BellIcon size={24} /> <BellIcon size={24} />
</div> </div>
<h2>{t("empty_title")}</h2> <h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p> <p>
{filter === "unread"
? t("empty_filter_description")
: t("empty_description")}
</p>
</div> </div>
); ) : (
}
return (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
</div>
<div className="notifications__list"> <div className="notifications__list">
<AnimatePresence mode="popLayout"> <AnimatePresence>
{displayedNotifications.map(renderNotification)} {displayedNotifications.map(renderNotification)}
</AnimatePresence> </AnimatePresence>
</div> </div>
)}
</motion.div>
</AnimatePresence>
{pagination.hasMore && ( {pagination.hasMore && !isClearing && (
<div className="notifications__load-more"> <div className="notifications__load-more">
<Button <Button
theme="outline" theme="outline"

View File

@@ -100,8 +100,10 @@
padding: calc(globals.$spacing-unit * 1.5); padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
border: none;
cursor: pointer; cursor: pointer;
transition: all ease 0.2s; transition: all ease 0.2s;
text-align: left;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);

View File

@@ -4,6 +4,20 @@
&__box { &__box {
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 2);
position: relative; position: relative;
&--empty {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
}
&__empty-text {
color: globals.$muted-color;
font-size: globals.$small-font-size;
margin: 0;
text-align: center;
} }
&__add-friend-button { &__add-friend-button {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext } from "react"; import { useCallback, useContext, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks"; import { useFormat, useUserDetails } from "@renderer/hooks";
@@ -7,9 +7,11 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react"; import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { Award } from "lucide-react"; import { Award } from "lucide-react";
import { WrappedFullscreenModal } from "./wrapped-tab";
import "./user-stats-box.scss"; import "./user-stats-box.scss";
export function UserStatsBox() { export function UserStatsBox() {
const [showWrappedModal, setShowWrappedModal] = useState(false);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { userStats, isMe, userProfile } = useContext(userProfileContext); const { userStats, isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
@@ -41,6 +43,18 @@ export function UserStatsBox() {
return ( return (
<div className="user-stats__box"> <div className="user-stats__box">
<ul className="user-stats__list"> <ul className="user-stats__list">
{userProfile?.hasCompletedWrapped2025 && (
<li className="user-stats__list-item user-stats__list-item--wrapped">
<button
type="button"
onClick={() => setShowWrappedModal(true)}
className="user-stats__wrapped-link"
>
Wrapped 2025
</button>
</li>
)}
{(isMe || userStats.unlockedAchievementSum !== undefined) && ( {(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item"> <li className="user-stats__list-item">
<h3 className="user-stats__list-title"> <h3 className="user-stats__list-title">
@@ -123,14 +137,17 @@ export function UserStatsBox() {
{t("karma_count")} {t("karma_count")}
</p> </p>
</div> </div>
<div className="user-stats__karma-info">
<small className="user-stats__karma-info-text">
{t("karma_description")}
</small>
</div>
</li> </li>
)} )}
</ul> </ul>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { UploadIcon } from "@primer/octicons-react"; import { TrashIcon, UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components"; import { MoreVertical } from "lucide-react";
import { useContext, useState } from "react"; import { Button, ConfirmationModal } from "@renderer/components";
import { createPortal } from "react-dom";
import { useContext, useEffect, useRef, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,16 +11,33 @@ import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() { export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] = const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false); useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { hasActiveSubscription } = useUserDetails(); const { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext); const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } =
useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails(); const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const handleChangeCoverClick = async () => { const hasBanner = !!userProfile?.backgroundImageUrl;
const closeMenu = () => {
setIsMenuClosing(true);
setTimeout(() => {
setIsMenuOpen(false);
setIsMenuClosing(false);
}, 150);
};
const handleReplaceBanner = async () => {
closeMenu();
try { try {
const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"], properties: ["openFile"],
@@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() {
showSuccessToast(t("background_image_updated")); showSuccessToast(t("background_image_updated"));
await fetchUserDetails(); await fetchUserDetails();
await getUserProfile();
} }
} finally { } finally {
setIsUploadingBackgorundImage(false); setIsUploadingBackgorundImage(false);
} }
}; };
const handleRemoveBannerClick = () => {
closeMenu();
setShowRemoveBannerModal(true);
};
const handleRemoveBannerConfirm = async () => {
setShowRemoveBannerModal(false);
try {
setIsUploadingBackgorundImage(true);
setSelectedBackgroundImage("");
await patchUser({ backgroundImageUrl: null });
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
} finally {
setIsUploadingBackgorundImage(false);
}
};
// Handle click outside, scroll, and escape key to close menu
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
buttonRef.current &&
!buttonRef.current.contains(target)
) {
closeMenu();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleScroll = () => {
closeMenu();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("scroll", handleScroll, true);
};
}, [isMenuOpen]);
if (!isMe || !hasActiveSubscription) return null; if (!isMe || !hasActiveSubscription) return null;
// If no banner exists, show the original upload button
if (!hasBanner) {
return ( return (
<div className="upload-background-image-button__wrapper">
<Button <Button
theme="outline" theme="outline"
className="upload-background-image-button" className="upload-background-image-button"
onClick={handleChangeCoverClick} onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage} disabled={isUploadingBackgroundImage}
> >
<UploadIcon /> <UploadIcon />
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")} {isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button> </Button>
</div>
);
}
// Calculate menu position
const getMenuPosition = () => {
if (!buttonRef.current) return { top: 0, right: 0 };
const rect = buttonRef.current.getBoundingClientRect();
return {
top: rect.bottom + 5,
right: window.innerWidth - rect.right,
};
};
const menuPosition = isMenuOpen ? getMenuPosition() : { top: 0, right: 0 };
const menuContent = isMenuOpen && (
<div
ref={menuRef}
className={`upload-background-image-button__menu ${
isMenuClosing ? "upload-background-image-button__menu--closing" : ""
}`}
style={{
position: "fixed",
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
}}
>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon size={16} />
{t("replace_banner")}
</button>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleRemoveBannerClick}
disabled={isUploadingBackgroundImage}
>
<TrashIcon size={16} />
{t("remove_banner")}
</button>
</div>
);
return (
<>
<div ref={buttonRef} className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
disabled={isUploadingBackgroundImage}
>
{t("change_banner")}
<MoreVertical size={16} />
</Button>
</div>
{createPortal(menuContent, document.body)}
<ConfirmationModal
visible={showRemoveBannerModal}
title={t("remove_banner_modal_title")}
descriptionText={t("remove_banner_confirmation")}
onClose={() => setShowRemoveBannerModal(false)}
onConfirm={handleRemoveBannerConfirm}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
buttonsIsDisabled={isUploadingBackgroundImage}
/>
</>
); );
} }

View File

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

View File

@@ -18,6 +18,13 @@
align-self: flex-start; align-self: flex-start;
} }
&__disabled-hint {
font-size: 13px;
color: globals.$muted-color;
margin-top: calc(globals.$spacing-unit * -1);
font-style: italic;
}
&__volume-control { &__volume-control {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -37,6 +37,12 @@ export function SettingsGeneral() {
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const lastPacket = useAppSelector((state) => state.download.lastPacket);
const hasActiveDownload =
lastPacket !== null &&
lastPacket.progress < 1 &&
!lastPacket.isDownloadingMetadata;
const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false); const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
const [installingCommonRedist, setInstallingCommonRedist] = useState(false); const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
@@ -53,6 +59,7 @@ export function SettingsGeneral() {
achievementSoundVolume: 15, achievementSoundVolume: 15,
language: "", language: "",
customStyles: window.localStorage.getItem("customStyles") || "", customStyles: window.localStorage.getItem("customStyles") || "",
useNativeHttpDownloader: true,
}); });
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]); const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
@@ -131,6 +138,8 @@ export function SettingsGeneral() {
friendStartGameNotificationsEnabled: friendStartGameNotificationsEnabled:
userPreferences.friendStartGameNotificationsEnabled ?? true, userPreferences.friendStartGameNotificationsEnabled ?? true,
language: language ?? "en", language: language ?? "en",
useNativeHttpDownloader:
userPreferences.useNativeHttpDownloader ?? true,
})); }));
} }
}, [userPreferences, defaultDownloadsPath]); }, [userPreferences, defaultDownloadsPath]);
@@ -248,6 +257,25 @@ export function SettingsGeneral() {
}))} }))}
/> />
<h2 className="settings-general__section-title">{t("downloads")}</h2>
<CheckboxField
label={t("use_native_http_downloader")}
checked={form.useNativeHttpDownloader}
disabled={hasActiveDownload}
onChange={() =>
handleChange({
useNativeHttpDownloader: !form.useNativeHttpDownloader,
})
}
/>
{hasActiveDownload && (
<p className="settings-general__disabled-hint">
{t("cannot_change_downloader_while_downloading")}
</p>
)}
<h2 className="settings-general__section-title">{t("notifications")}</h2> <h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField <CheckboxField

View File

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

View File

@@ -3,13 +3,14 @@ export enum Downloader {
Torrent, Torrent,
Gofile, Gofile,
PixelDrain, PixelDrain,
Qiwi,
Datanodes, Datanodes,
Mediafire, Mediafire,
TorBox, TorBox,
Hydra, Hydra,
Buzzheavier, Buzzheavier,
FuckingFast, FuckingFast,
VikingFile,
Rootz,
} }
export enum DownloadSourceStatus { export enum DownloadSourceStatus {

View File

@@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile]; if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain]; if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes]; if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
if (uri.startsWith("https://www.mediafire.com")) if (uri.startsWith("https://www.mediafire.com"))
return [Downloader.Mediafire]; return [Downloader.Mediafire];
@@ -124,6 +123,12 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://fuckingfast.co")) { if (uri.startsWith("https://fuckingfast.co")) {
return [Downloader.FuckingFast]; return [Downloader.FuckingFast];
} }
if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile];
}
if (uri.startsWith("https://www.rootz.so")) {
return [Downloader.Rootz];
}
if (realDebridHosts.some((host) => uri.startsWith(host))) if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid]; return [Downloader.RealDebrid];

View File

@@ -20,6 +20,7 @@ export interface GameRepack {
title: string; title: string;
fileSize: string | null; fileSize: string | null;
uris: string[]; uris: string[];
unavailableUris: string[];
uploadDate: string | null; uploadDate: string | null;
downloadSourceId: string; downloadSourceId: string;
downloadSourceName: string; downloadSourceName: string;
@@ -187,6 +188,7 @@ export interface UserDetails {
profileVisibility: ProfileVisibility; profileVisibility: ProfileVisibility;
bio: string; bio: string;
featurebaseJwt: string; featurebaseJwt: string;
workwondersJwt: string;
subscription: Subscription | null; subscription: Subscription | null;
karma: number; karma: number;
quirks?: { quirks?: {
@@ -328,7 +330,8 @@ export type LocalNotificationType =
| "EXTRACTION_COMPLETE" | "EXTRACTION_COMPLETE"
| "DOWNLOAD_COMPLETE" | "DOWNLOAD_COMPLETE"
| "UPDATE_AVAILABLE" | "UPDATE_AVAILABLE"
| "ACHIEVEMENT_UNLOCKED"; | "ACHIEVEMENT_UNLOCKED"
| "SCAN_GAMES_COMPLETE";
export interface Notification { export interface Notification {
id: string; id: string;

View File

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

134
yarn.lock
View File

@@ -2174,6 +2174,60 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz#5b2dd648a960b8fa00d76f2cc4eea2f03daa80f4" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz#5b2dd648a960b8fa00d76f2cc4eea2f03daa80f4"
integrity sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w== integrity sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==
"@sentry-internal/browser-utils@10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.33.0.tgz#4a5d98352267b63fcc449efe14627c0fc082089e"
integrity sha512-nDJFHAfiFifBfJB0OF6DV6BIsIV5uah4lDsV4UBAgPBf+YAHclO10y1gi2U/JMh58c+s4lXi9p+PI1TFXZ0c6w==
dependencies:
"@sentry/core" "10.33.0"
"@sentry-internal/feedback@10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.33.0.tgz#5865b4a68d607bb48d8159a100464ae640a638e7"
integrity sha512-sN/VLWtEf0BeV6w6wldIpTxUQxNVc9o9tjLRQa8je1ZV2FCgXA124Iff/zsowsz82dLqtg7qp6GA5zYXVq+JMA==
dependencies:
"@sentry/core" "10.33.0"
"@sentry-internal/replay-canvas@10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.33.0.tgz#9ea15b320618ad220e5d8f7c804a0d9ca55b04af"
integrity sha512-MTmP6uoAVzw4CCPeqCgCLsRSiOfGLxgyMFjGTCW3E7t62MJ9S0H5sLsQ34sHxXUa1gFU9UNAjEvRRpZ0JvWrPw==
dependencies:
"@sentry-internal/replay" "10.33.0"
"@sentry/core" "10.33.0"
"@sentry-internal/replay@10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.33.0.tgz#8cfe3a353731fcd81e7afb646b6befeb0f9feb0f"
integrity sha512-UOU9PYxuXnPop3HoQ3l4Q7SZUXJC3Vmfm0Adgad8U03UcrThWIHYc5CxECSrVzfDFNOT7w9o7HQgRAgWxBPMXg==
dependencies:
"@sentry-internal/browser-utils" "10.33.0"
"@sentry/core" "10.33.0"
"@sentry/browser@10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.33.0.tgz#33284952a1cdf43cdac15ac144c85e81e7cbaa93"
integrity sha512-iWiPjik9zetM84jKfk01UveW1J0+X7w8XmJ8+IrhTyNDBVUWCRJWD8FrksiN1dRSg5mFWgfMRzKMz27hAScRwg==
dependencies:
"@sentry-internal/browser-utils" "10.33.0"
"@sentry-internal/feedback" "10.33.0"
"@sentry-internal/replay" "10.33.0"
"@sentry-internal/replay-canvas" "10.33.0"
"@sentry/core" "10.33.0"
"@sentry/core@10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.33.0.tgz#ea4964fbec290503b419ccaf1a313924d30ad1c8"
integrity sha512-ehH1VSUclIHZKEZVdv+klofsFIh8FFzqA6AAV23RtLepptzA8wqQzUGraEuSN25sYcNmYJ0jti5U0Ys+WZv5Dw==
"@sentry/react@^10.33.0":
version "10.33.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.33.0.tgz#89a3be88d43e49de90943ad2ac86ee1664048097"
integrity sha512-iMdC2Iw54ibAccatJ5TjoLlIy3VotFteied7JFvOudgj1/2eBBeWthRobZ5p6/nAOpj4p9vJk0DeLrc012sd2g==
dependencies:
"@sentry/browser" "10.33.0"
"@sentry/core" "10.33.0"
"@sindresorhus/is@^4.0.0": "@sindresorhus/is@^4.0.0":
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
@@ -2203,14 +2257,15 @@
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1": "@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1":
version "4.3.1" version "4.4.5"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.3.1.tgz#f1a0ed6faa52377909440002e1632be9fc901840" resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.5.tgz#35e792b6db00887bdd029df9b41780ca005d064b"
integrity sha512-tWDwrWy37CDVGeaP8AIGZPFL2RoFtmd5Y+nTzLw5qroXNedT2S66EY2d+XzB1zxulCd6nfDXnAQu4auq90aj5Q== integrity sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==
dependencies: dependencies:
"@smithy/node-config-provider" "^4.3.1" "@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.7.0" "@smithy/types" "^4.11.0"
"@smithy/util-config-provider" "^4.2.0" "@smithy/util-config-provider" "^4.2.0"
"@smithy/util-middleware" "^4.2.1" "@smithy/util-endpoints" "^3.2.7"
"@smithy/util-middleware" "^4.2.7"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/core@^3.15.0", "@smithy/core@^3.16.0": "@smithy/core@^3.15.0", "@smithy/core@^3.16.0":
@@ -2421,6 +2476,16 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/node-config-provider@^4.3.7":
version "4.3.7"
resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz#c023fa857b008c314f621fb5b124724c157b2fd3"
integrity sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==
dependencies:
"@smithy/property-provider" "^4.2.7"
"@smithy/shared-ini-file-loader" "^4.4.2"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0": "@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0":
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83"
@@ -2440,6 +2505,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/property-provider@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.7.tgz#cd0044e13495cf4064b3a6ed3299e5f549ba7513"
integrity sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1": "@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18" resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18"
@@ -2480,6 +2553,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/shared-ini-file-loader@^4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz#8fa1b459de485b11185fe8c64182e3205a280ba9"
integrity sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/signature-v4@^5.3.0": "@smithy/signature-v4@^5.3.0":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4"
@@ -2507,6 +2588,13 @@
"@smithy/util-stream" "^4.5.1" "@smithy/util-stream" "^4.5.1"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/types@^4.11.0":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.11.0.tgz#c02f6184dcb47c4f0b387a32a7eca47956cc09f1"
integrity sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==
dependencies:
tslib "^2.6.2"
"@smithy/types@^4.6.0", "@smithy/types@^4.7.0": "@smithy/types@^4.6.0", "@smithy/types@^4.7.0":
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f"
@@ -2601,6 +2689,15 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/util-endpoints@^3.2.7":
version "3.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz#78cd5dd4aac8d9977f49d256d1e3418a09cade72"
integrity sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==
dependencies:
"@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-hex-encoding@^4.2.0": "@smithy/util-hex-encoding@^4.2.0":
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b" resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b"
@@ -2616,6 +2713,14 @@
"@smithy/types" "^4.7.0" "@smithy/types" "^4.7.0"
tslib "^2.6.2" tslib "^2.6.2"
"@smithy/util-middleware@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.7.tgz#1cae2c4fd0389ac858d29f7170c33b4443e83524"
integrity sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1": "@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1":
version "4.2.1" version "4.2.1"
resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521" resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521"
@@ -5482,6 +5587,11 @@ get-nonce@^1.0.0:
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-port@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec"
integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==
get-proto@^1.0.0, get-proto@^1.0.1: get-proto@^1.0.0, get-proto@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
@@ -6354,6 +6464,11 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies: dependencies:
json-buffer "3.0.1" json-buffer "3.0.1"
ky@^1.14.2:
version "1.14.2"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.14.2.tgz#385d6d05d2825502e68898ace125124e6fe7357d"
integrity sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==
language-subtag-registry@^0.3.20: language-subtag-registry@^0.3.20:
version "0.3.23" version "0.3.23"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
@@ -9123,6 +9238,13 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
workwonders-sdk@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/workwonders-sdk/-/workwonders-sdk-0.1.1.tgz#7ac0eb3d9ef0a5a8cc5ca4e6f5e387e29875faa9"
integrity sha512-PEsl33QCeiBlYed/MmnX1unnd4Kn7vzVIza00HQ/5Zsan89nqnwWx9vqgJnNipXkkmIWl8oDL9bGRNjtL4XZ4Q==
dependencies:
ky "^1.14.2"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"