mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-16 00:43:00 -03:00
Compare commits
242 Commits
feat/addin
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7293afb618 | ||
|
|
194e7918ca | ||
|
|
979958aca6 | ||
|
|
6e92e0f79f | ||
|
|
aef069d4c7 | ||
|
|
1f447cc478 | ||
|
|
5d2dc3616c | ||
|
|
1f9972f74e | ||
|
|
3344f68408 | ||
|
|
65be11cc07 | ||
|
|
7e78a0f9f1 | ||
|
|
d56cc8695b | ||
|
|
3f7b9e2a0b | ||
|
|
3b3926156e | ||
|
|
0089e08ba7 | ||
|
|
bced12d077 | ||
|
|
f8ba72a0e2 | ||
|
|
2029f861f6 | ||
|
|
46e248c62a | ||
|
|
dba8f9fb22 | ||
|
|
b565ef7f00 | ||
|
|
9298d9aa09 | ||
|
|
5b05fc2644 | ||
|
|
605d064ec0 | ||
|
|
c0956b1bc1 | ||
|
|
2e152d321e | ||
|
|
a553b049ba | ||
|
|
467b27baa3 | ||
|
|
4342a4a5d5 | ||
|
|
d9d443ee6d | ||
|
|
a912b57ccc | ||
|
|
447c146035 | ||
|
|
39ff44f9d1 | ||
|
|
dbe101b7df | ||
|
|
5e4e03a958 | ||
|
|
da0ae54b60 | ||
|
|
562e30eecf | ||
|
|
e7a62c16fa | ||
|
|
ed044d797f | ||
|
|
ed3cce160f | ||
|
|
c67b275657 | ||
|
|
a2e866317d | ||
|
|
a7c82de4a7 | ||
|
|
027761a1b5 | ||
|
|
ca2f70aede | ||
|
|
2b3a8bf6b6 | ||
|
|
81b3ad3612 | ||
|
|
8f477072ba | ||
|
|
569700e85c | ||
|
|
4975f2def9 | ||
|
|
77af7509ac | ||
|
|
f37ccbb4c0 | ||
|
|
feb8d78e01 | ||
|
|
44f39d94c4 | ||
|
|
e618d313b3 | ||
|
|
7eec87c192 | ||
|
|
96140e614c | ||
|
|
2ccc93ea61 | ||
|
|
7e7390885e | ||
|
|
64815f4f8d | ||
|
|
4dfdc4d798 | ||
|
|
9bbfab2aff | ||
|
|
01938f8905 | ||
|
|
f60ad5908d | ||
|
|
fe6553bcdc | ||
|
|
87895bb715 | ||
|
|
290209f372 | ||
|
|
87fcbaa56e | ||
|
|
c32ce14630 | ||
|
|
e52f10a5ff | ||
|
|
bcdbe31596 | ||
|
|
7ed514b6ef | ||
|
|
42386ae0b5 | ||
|
|
04d8f900a6 | ||
|
|
8b3bcd88b1 | ||
|
|
b2bffeb2b0 | ||
|
|
0a194eaa29 | ||
|
|
07c277c033 | ||
|
|
345696ad06 | ||
|
|
6c4e8c406f | ||
|
|
c46a1e7848 | ||
|
|
590e09a8c3 | ||
|
|
c1d7ea27f3 | ||
|
|
15dbd3b2ad | ||
|
|
4584783f44 | ||
|
|
765ec70dd0 | ||
|
|
de483da51c | ||
|
|
2bc0266775 | ||
|
|
c9729fb3eb | ||
|
|
9a7ad148e3 | ||
|
|
d929fbaeaa | ||
|
|
8fa33119d6 | ||
|
|
92d87c5d33 | ||
|
|
af884d3772 | ||
|
|
dc31ac0831 | ||
|
|
9769eecec6 | ||
|
|
91adb97013 | ||
|
|
f138b2efcb | ||
|
|
991aa05760 | ||
|
|
aff9e13bca | ||
|
|
240a75c1d0 | ||
|
|
edbe86a1fb | ||
|
|
a01e1b1709 | ||
|
|
60fd90820c | ||
|
|
798f88618e | ||
|
|
40795c34dc | ||
|
|
e335e05628 | ||
|
|
05464f25df | ||
|
|
b9830afca1 | ||
|
|
1cab73bcb4 | ||
|
|
27462c1e1e | ||
|
|
98dc20092a | ||
|
|
9faf34a976 | ||
|
|
d25ac69e74 | ||
|
|
d3fb967229 | ||
|
|
35736dd2d9 | ||
|
|
263d0be4e4 | ||
|
|
0b4d31e482 | ||
|
|
a74b557d13 | ||
|
|
86d5547aa1 | ||
|
|
358f41b4ba | ||
|
|
7f0dc5eee4 | ||
|
|
067f7a00be | ||
|
|
37f085e2c0 | ||
|
|
f8ac284bc2 | ||
|
|
cea5afc7f7 | ||
|
|
dff8d02f3f | ||
|
|
8751e369da | ||
|
|
45eaef23a9 | ||
|
|
3c296fe721 | ||
|
|
1d1bbd2de5 | ||
|
|
246fc14b75 | ||
|
|
387b3ebeac | ||
|
|
b1d72828bb | ||
|
|
4c09f915c6 | ||
|
|
24d65b50b4 | ||
|
|
f77b2116c1 | ||
|
|
6cd65d6239 | ||
|
|
34681b3bc2 | ||
|
|
4b71a3f5bb | ||
|
|
a0f669c97b | ||
|
|
96fca88601 | ||
|
|
3f65bb86a8 | ||
|
|
b494c7c8ec | ||
|
|
e10007b1c3 | ||
|
|
dae825a75a | ||
|
|
677f34fe3d | ||
|
|
1524e73ee6 | ||
|
|
40d428c19e | ||
|
|
affa7a2b2e | ||
|
|
cf16c8245c | ||
|
|
6257529297 | ||
|
|
b8352be274 | ||
|
|
d5e6bed3b7 | ||
|
|
214267df7e | ||
|
|
21f46c9af3 | ||
|
|
cc5e0014f7 | ||
|
|
ccb754fa13 | ||
|
|
142bd3156c | ||
|
|
95a7bc2236 | ||
|
|
78d2be85f2 | ||
|
|
67ea9e78a2 | ||
|
|
67f863e0f3 | ||
|
|
77b6f1b2ad | ||
|
|
5329cc446f | ||
|
|
21a0ad1500 | ||
|
|
9ffaee12d1 | ||
|
|
8555274589 | ||
|
|
a152c89d7f | ||
|
|
879f0baad7 | ||
|
|
c025dc199d | ||
|
|
1552a5f359 | ||
|
|
5b4b258526 | ||
|
|
0268829946 | ||
|
|
63f8289d0a | ||
|
|
0470958629 | ||
|
|
3b574e6578 | ||
|
|
7f28fc8ca1 | ||
|
|
d1eb174429 | ||
|
|
82a125237b | ||
|
|
19e312d31e | ||
|
|
79b1f05cde | ||
|
|
cc9ac9dc0f | ||
|
|
19406dd051 | ||
|
|
8aa6e113e7 | ||
|
|
3f41f0f7ad | ||
|
|
91ad4a68f7 | ||
|
|
a69a6ec510 | ||
|
|
fada6507c3 | ||
|
|
0479f1347b | ||
|
|
817870cdbb | ||
|
|
f44d5c8b49 | ||
|
|
c36109c092 | ||
|
|
b59fb7dc36 | ||
|
|
214a7af408 | ||
|
|
14679fc31e | ||
|
|
1545f42d17 | ||
|
|
e872b2ea8a | ||
|
|
dd7c84b433 | ||
|
|
1546da29cf | ||
|
|
a89b0bb2a8 | ||
|
|
9bdb216e0f | ||
|
|
9779aed8c1 | ||
|
|
058a148c7f | ||
|
|
16e3d52508 | ||
|
|
7e0002cf95 | ||
|
|
bf8b3ca836 | ||
|
|
77e376e742 | ||
|
|
bd28b202c4 | ||
|
|
153b954e78 | ||
|
|
a9e63730be | ||
|
|
316480930d | ||
|
|
0b5c9acaaa | ||
|
|
814a2da05c | ||
|
|
0ad1ebd6a2 | ||
|
|
e9de8264e2 | ||
|
|
b135087ffe | ||
|
|
b4a1af78a6 | ||
|
|
ede5bb0c23 | ||
|
|
9a27875cd8 | ||
|
|
cf20a942ae | ||
|
|
256d829a60 | ||
|
|
8cb18578e0 | ||
|
|
62950297e0 | ||
|
|
3eecc42430 | ||
|
|
f6edb45628 | ||
|
|
de8797bea6 | ||
|
|
828f82f647 | ||
|
|
bb22d9c4dd | ||
|
|
559bb45acc | ||
|
|
e176e624be | ||
|
|
4e92e794be | ||
|
|
de0dbcac35 | ||
|
|
07d5a5b3f3 | ||
|
|
a1117c8269 | ||
|
|
2adc132c33 | ||
|
|
c4852b89f1 | ||
|
|
5bffaf17fa | ||
|
|
cc38be4383 | ||
|
|
0b70a28c08 | ||
|
|
3ff50a9932 | ||
|
|
83fbf20383 |
13
.cursorrules
13
.cursorrules
@@ -28,6 +28,19 @@
|
|||||||
- Use async/await instead of promises when possible
|
- Use async/await instead of promises when possible
|
||||||
- Prefer named exports over default exports for utilities and services
|
- Prefer named exports over default exports for utilities and services
|
||||||
|
|
||||||
|
## ESLint Issues
|
||||||
|
|
||||||
|
- **Always try to fix ESLint errors properly before disabling rules**
|
||||||
|
- When encountering ESLint errors, explore these solutions in order:
|
||||||
|
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
|
||||||
|
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
|
||||||
|
3. **Only disable the rule as a last resort** when no reasonable solution exists
|
||||||
|
- When disabling a rule, always include a comment explaining why it's necessary
|
||||||
|
- Examples of proper fixes:
|
||||||
|
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
|
||||||
|
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
|
||||||
|
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
|
||||||
|
|
||||||
## TypeScript Array Syntax
|
## TypeScript Array Syntax
|
||||||
|
|
||||||
- **Always use `T[]` syntax instead of `Array<T>`** for array types
|
- **Always use `T[]` syntax instead of `Array<T>`** for array types
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
1
.github/workflows/build-renderer.yml
vendored
1
.github/workflows/build-renderer.yml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -57,6 +57,7 @@ jobs:
|
|||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
MAIN_VITE_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 }}
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -54,9 +54,10 @@ jobs:
|
|||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_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 }}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
[](https://github.com/hydralauncher/hydra/actions)
|
[](https://github.com/hydralauncher/hydra/actions)
|
||||||
[](https://github.com/hydralauncher/hydra/releases)
|
[](https://github.com/hydralauncher/hydra/releases)
|
||||||
|
[](https://community.chocolatey.org/packages/hydralauncher)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "3.7.4",
|
"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,12 +64,14 @@
|
|||||||
"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",
|
||||||
|
"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",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
|
"node-7z": "^3.0.0",
|
||||||
"parse-torrent": "^11.0.18",
|
"parse-torrent": "^11.0.18",
|
||||||
"rc-virtual-list": "^3.18.3",
|
"rc-virtual-list": "^3.18.3",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
@@ -84,11 +87,12 @@
|
|||||||
"sound-play": "^1.1.0",
|
"sound-play": "^1.1.0",
|
||||||
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
|
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
|
||||||
"sudo-prompt": "^9.2.1",
|
"sudo-prompt": "^9.2.1",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.5.2",
|
||||||
"tough-cookie": "^5.1.1",
|
"tough-cookie": "^5.1.1",
|
||||||
"user-agents": "^1.1.387",
|
"user-agents": "^1.1.387",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winreg": "^1.2.5",
|
"winreg": "^1.2.5",
|
||||||
|
"workwonders-sdk": "0.0.10",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"yaml": "^2.6.1",
|
"yaml": "^2.6.1",
|
||||||
"yup": "^1.5.0"
|
"yup": "^1.5.0"
|
||||||
|
|||||||
2
proto
2
proto
Submodule proto updated: 7a23620f93...6f11c99c57
@@ -1,4 +1,5 @@
|
|||||||
import aria2p
|
import aria2p
|
||||||
|
from aria2p.client import ClientException as DownloadNotFound
|
||||||
|
|
||||||
class HttpDownloader:
|
class HttpDownloader:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -11,12 +12,16 @@ class HttpDownloader:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_download(self, url: str, save_path: str, header: str, out: str = None):
|
def start_download(self, url: str, save_path: str, header, out: str = None):
|
||||||
if self.download:
|
if self.download:
|
||||||
self.aria2.resume([self.download])
|
self.aria2.resume([self.download])
|
||||||
else:
|
else:
|
||||||
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
|
options = {"dir": save_path}
|
||||||
|
if header:
|
||||||
|
options["header"] = header
|
||||||
|
if out:
|
||||||
|
options["out"] = out
|
||||||
|
downloads = self.aria2.add(url, options=options)
|
||||||
self.download = downloads[0]
|
self.download = downloads[0]
|
||||||
|
|
||||||
def pause_download(self):
|
def pause_download(self):
|
||||||
@@ -32,7 +37,11 @@ class HttpDownloader:
|
|||||||
if self.download == None:
|
if self.download == None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
download = self.aria2.get_download(self.download.gid)
|
try:
|
||||||
|
download = self.aria2.get_download(self.download.gid)
|
||||||
|
except DownloadNotFound:
|
||||||
|
self.download = None
|
||||||
|
return None
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
'folderName': download.name,
|
'folderName': download.name,
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"game_has_no_executable": "Game has no executable selected",
|
"game_has_no_executable": "Game has no executable selected",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
|
"notifications": "Notifications",
|
||||||
"need_help": "Need help?",
|
"need_help": "Need help?",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"playable_button_title": "Show only games you can play now",
|
"playable_button_title": "Show only games you can play now",
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
"search_library": "Search library",
|
"search_library": "Search library",
|
||||||
"recent_searches": "Recent Searches",
|
"recent_searches": "Recent Searches",
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "Suggestions",
|
||||||
"clear_history": "Clear history",
|
"clear_history": "Clear",
|
||||||
"remove_from_history": "Remove from history",
|
"remove_from_history": "Remove from history",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"no_results": "No results",
|
"no_results": "No results",
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
|
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
|
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
|
||||||
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
|
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
|
||||||
|
"extracting": "Extracting {{title}}… ({{percentage}} complete)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Installation complete",
|
"installation_complete": "Installation complete",
|
||||||
"installation_complete_message": "Common redistributables installed successfully"
|
"installation_complete_message": "Common redistributables installed successfully"
|
||||||
@@ -173,6 +175,7 @@
|
|||||||
"repacks_modal_description": "Choose the repack you want to download",
|
"repacks_modal_description": "Choose the repack you want to download",
|
||||||
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
|
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
|
||||||
"download_now": "Download now",
|
"download_now": "Download now",
|
||||||
|
"loading": "Loading...",
|
||||||
"no_shop_details": "Could not retrieve shop details.",
|
"no_shop_details": "Could not retrieve shop details.",
|
||||||
"download_options": "Download options",
|
"download_options": "Download options",
|
||||||
"download_path": "Download path",
|
"download_path": "Download path",
|
||||||
@@ -182,6 +185,12 @@
|
|||||||
"open_screenshot": "Open screenshot {{number}}",
|
"open_screenshot": "Open screenshot {{number}}",
|
||||||
"download_settings": "Download settings",
|
"download_settings": "Download settings",
|
||||||
"downloader": "Downloader",
|
"downloader": "Downloader",
|
||||||
|
"downloader_online": "Online",
|
||||||
|
"downloader_not_configured": "Available but not configured",
|
||||||
|
"downloader_offline": "Link is offline",
|
||||||
|
"downloader_not_available": "Not available",
|
||||||
|
"recommended": "Recommended",
|
||||||
|
"go_to_settings": "Go to Settings",
|
||||||
"select_executable": "Select",
|
"select_executable": "Select",
|
||||||
"no_executable_selected": "No executable selected",
|
"no_executable_selected": "No executable selected",
|
||||||
"open_folder": "Open folder",
|
"open_folder": "Open folder",
|
||||||
@@ -202,6 +211,7 @@
|
|||||||
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
|
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
|
||||||
"download_in_progress": "Download in progress",
|
"download_in_progress": "Download in progress",
|
||||||
"download_paused": "Download paused",
|
"download_paused": "Download paused",
|
||||||
|
"extracting": "Extracting",
|
||||||
"last_downloaded_option": "Last downloaded option",
|
"last_downloaded_option": "Last downloaded option",
|
||||||
"new_download_option": "New",
|
"new_download_option": "New",
|
||||||
"create_steam_shortcut": "Create Steam shortcut",
|
"create_steam_shortcut": "Create Steam shortcut",
|
||||||
@@ -394,6 +404,10 @@
|
|||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"removed": "Not downloaded",
|
"removed": "Not downloaded",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"cancel_download": "Cancel download?",
|
||||||
|
"cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.",
|
||||||
|
"keep_downloading": "No, keep downloading",
|
||||||
|
"yes_cancel": "Yes, cancel",
|
||||||
"filter": "Filter downloaded games",
|
"filter": "Filter downloaded games",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"downloading_metadata": "Downloading metadata…",
|
"downloading_metadata": "Downloading metadata…",
|
||||||
@@ -414,7 +428,13 @@
|
|||||||
"resume_seeding": "Resume seeding",
|
"resume_seeding": "Resume seeding",
|
||||||
"options": "Manage",
|
"options": "Manage",
|
||||||
"extract": "Extract files",
|
"extract": "Extract files",
|
||||||
"extracting": "Extracting files…"
|
"extracting": "Extracting files…",
|
||||||
|
"delete_archive_title": "Would you like to delete {{fileName}}?",
|
||||||
|
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"network": "NETWORK",
|
||||||
|
"peak": "PEAK"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Downloads path",
|
"downloads_path": "Downloads path",
|
||||||
@@ -550,6 +570,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
|
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
|
||||||
"extract_files_by_default": "Extract files by default after download",
|
"extract_files_by_default": "Extract files by default after download",
|
||||||
"enable_steam_achievements": "Enable search for Steam achievements",
|
"enable_steam_achievements": "Enable search for Steam achievements",
|
||||||
|
"enable_new_download_options_badges": "Show new download options badges",
|
||||||
"achievement_custom_notification_position": "Achievement custom notification position",
|
"achievement_custom_notification_position": "Achievement custom notification position",
|
||||||
"top-left": "Top left",
|
"top-left": "Top left",
|
||||||
"top-center": "Top center",
|
"top-center": "Top center",
|
||||||
@@ -577,7 +598,10 @@
|
|||||||
"notification_preview": "Achievement Notification Preview",
|
"notification_preview": "Achievement Notification Preview",
|
||||||
"enable_friend_start_game_notifications": "When a friend starts playing a game",
|
"enable_friend_start_game_notifications": "When a friend starts playing a game",
|
||||||
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
|
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
|
||||||
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup"
|
"hide_to_tray_on_game_start": "Hide Hydra to tray on game startup",
|
||||||
|
"downloads": "Downloads",
|
||||||
|
"use_native_http_downloader": "Use native HTTP downloader (experimental)",
|
||||||
|
"cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download complete",
|
"download_complete": "Download complete",
|
||||||
@@ -654,6 +678,7 @@
|
|||||||
"sending": "Sending",
|
"sending": "Sending",
|
||||||
"friend_request_sent": "Friend request sent",
|
"friend_request_sent": "Friend request sent",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
|
"badges": "Badges",
|
||||||
"friends_list": "Friends list",
|
"friends_list": "Friends list",
|
||||||
"user_not_found": "User not found",
|
"user_not_found": "User not found",
|
||||||
"block_user": "Block user",
|
"block_user": "Block user",
|
||||||
@@ -664,12 +689,17 @@
|
|||||||
"ignore_request": "Ignore request",
|
"ignore_request": "Ignore request",
|
||||||
"cancel_request": "Cancel request",
|
"cancel_request": "Cancel request",
|
||||||
"undo_friendship": "Undo friendship",
|
"undo_friendship": "Undo friendship",
|
||||||
|
"friendship_removed": "Friend removed",
|
||||||
"request_accepted": "Request accepted",
|
"request_accepted": "Request accepted",
|
||||||
"user_blocked_successfully": "User blocked successfully",
|
"user_blocked_successfully": "User blocked successfully",
|
||||||
"user_block_modal_text": "This will block {{displayName}}",
|
"user_block_modal_text": "This will block {{displayName}}",
|
||||||
"blocked_users": "Blocked users",
|
"blocked_users": "Blocked users",
|
||||||
"unblock": "Unblock",
|
"unblock": "Unblock",
|
||||||
"no_friends_added": "You have no added friends",
|
"no_friends_added": "You have no added friends",
|
||||||
|
"no_friends_yet": "You haven't added any friends yet",
|
||||||
|
"view_all": "View all",
|
||||||
|
"load_more": "Load more",
|
||||||
|
"loading": "Loading",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"no_pending_invites": "You have no pending invites",
|
"no_pending_invites": "You have no pending invites",
|
||||||
"no_blocked_users": "You have no blocked users",
|
"no_blocked_users": "You have no blocked users",
|
||||||
@@ -693,8 +723,16 @@
|
|||||||
"report_reason_other": "Other",
|
"report_reason_other": "Other",
|
||||||
"profile_reported": "Profile reported",
|
"profile_reported": "Profile reported",
|
||||||
"your_friend_code": "Your friend code:",
|
"your_friend_code": "Your friend code:",
|
||||||
|
"copy_friend_code": "Copy friend code",
|
||||||
|
"copied": "Copied!",
|
||||||
"upload_banner": "Upload banner",
|
"upload_banner": "Upload banner",
|
||||||
"uploading_banner": "Uploading banner…",
|
"uploading_banner": "Uploading banner…",
|
||||||
|
"change_banner": "Change banner",
|
||||||
|
"replace_banner": "Replace banner",
|
||||||
|
"remove_banner": "Remove banner",
|
||||||
|
"remove_banner_modal_title": "Remove banner?",
|
||||||
|
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
|
||||||
|
"remove": "Remove",
|
||||||
"background_image_updated": "Background image updated",
|
"background_image_updated": "Background image updated",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"achievements": "achievements",
|
"achievements": "achievements",
|
||||||
@@ -712,10 +750,10 @@
|
|||||||
"game_added_to_pinned": "Game added to pinned",
|
"game_added_to_pinned": "Game added to pinned",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "Earned from positive likes on reviews",
|
|
||||||
"user_reviews": "Reviews",
|
"user_reviews": "Reviews",
|
||||||
"delete_review": "Delete Review",
|
"delete_review": "Delete Review",
|
||||||
"loading_reviews": "Loading reviews..."
|
"loading_reviews": "Loading reviews...",
|
||||||
|
"wrapped_2025": "Wrapped 2025"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
@@ -766,5 +804,41 @@
|
|||||||
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
||||||
"learn_more": "Learn More",
|
"learn_more": "Learn More",
|
||||||
"debrid_description": "Download up to 4x faster with Nimbus"
|
"debrid_description": "Download up to 4x faster with Nimbus"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"mark_all_as_read": "Mark all as read",
|
||||||
|
"clear_all": "Clear All",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"empty_title": "No notifications",
|
||||||
|
"empty_description": "You're all caught up! Check back later for new updates.",
|
||||||
|
"empty_filter_description": "No notifications match this filter.",
|
||||||
|
"filter_all": "All",
|
||||||
|
"filter_unread": "Unread",
|
||||||
|
"filter_friends": "Friends",
|
||||||
|
"filter_badges": "Badges",
|
||||||
|
"filter_upvotes": "Upvotes",
|
||||||
|
"filter_local": "Local",
|
||||||
|
"load_more": "Load more",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"accept": "Accept",
|
||||||
|
"refuse": "Refuse",
|
||||||
|
"notification": "Notification",
|
||||||
|
"friend_request_received_title": "New friend request!",
|
||||||
|
"friend_request_received_description": "{{displayName}} wants to be your friend",
|
||||||
|
"friend_request_accepted_title": "Friend request accepted!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
|
||||||
|
"badge_received_title": "You got a new badge!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
|
||||||
|
"review_upvote_description": "Your review received {{count}} new upvotes",
|
||||||
|
"marked_all_as_read": "All notifications marked as read",
|
||||||
|
"failed_to_mark_as_read": "Failed to mark notifications as read",
|
||||||
|
"cleared_all": "All notifications cleared",
|
||||||
|
"failed_to_clear": "Failed to clear notifications",
|
||||||
|
"failed_to_load": "Failed to load notifications",
|
||||||
|
"failed_to_dismiss": "Failed to dismiss notification",
|
||||||
|
"friend_request_accepted": "Friend request accepted",
|
||||||
|
"friend_request_refused": "Friend request refused"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -93,8 +94,16 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Buscar juegos",
|
"search": "Buscar juegos",
|
||||||
|
"search_library": "Buscar en la librería",
|
||||||
|
"recent_searches": "Búsquedas Recientes",
|
||||||
|
"suggestions": "Sugerencias",
|
||||||
|
"clear_history": "Limpiar",
|
||||||
|
"remove_from_history": "Eliminar del historial",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"no_results": "Sin resultados",
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"catalogue": "Catálogo",
|
"catalogue": "Catálogo",
|
||||||
|
"library": "Librería",
|
||||||
"downloads": "Descargas",
|
"downloads": "Descargas",
|
||||||
"search_results": "Resultados de búsqueda",
|
"search_results": "Resultados de búsqueda",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
@@ -107,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"
|
||||||
@@ -165,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",
|
||||||
@@ -174,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",
|
||||||
@@ -192,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",
|
||||||
@@ -386,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…",
|
||||||
@@ -406,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",
|
||||||
@@ -450,6 +478,7 @@
|
|||||||
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
|
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
|
||||||
"button_delete_all_sources": "Eliminar todo",
|
"button_delete_all_sources": "Eliminar todo",
|
||||||
"added_download_source": "Añadir fuente de descarga",
|
"added_download_source": "Añadir fuente de descarga",
|
||||||
|
"adding": "Añadiendo…",
|
||||||
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
|
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
|
||||||
"insert_valid_json_url": "Introducí una URL de json válida",
|
"insert_valid_json_url": "Introducí una URL de json válida",
|
||||||
"found_download_option_zero": "Sin opciones de descargas encontrada",
|
"found_download_option_zero": "Sin opciones de descargas encontrada",
|
||||||
@@ -529,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",
|
||||||
@@ -555,7 +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",
|
||||||
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
|
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego",
|
||||||
|
"downloads": "Descargas",
|
||||||
|
"use_native_http_downloader": "Usar descargador HTTP nativo (experimental)",
|
||||||
|
"cannot_change_downloader_while_downloading": "No se puede cambiar esta configuración mientras una descarga está en progreso"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Descarga completada",
|
"download_complete": "Descarga completada",
|
||||||
@@ -629,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",
|
||||||
@@ -639,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",
|
||||||
@@ -668,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",
|
||||||
@@ -688,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"
|
||||||
},
|
},
|
||||||
@@ -745,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -27,7 +27,69 @@
|
|||||||
"friends": "Amis",
|
"friends": "Amis",
|
||||||
"need_help": "Besoin d'aide ?",
|
"need_help": "Besoin d'aide ?",
|
||||||
"favorites": "Favoris",
|
"favorites": "Favoris",
|
||||||
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
|
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
|
||||||
|
"library": "Bibliothèque",
|
||||||
|
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
|
||||||
|
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
|
||||||
|
"custom_game_modal": "Ajouter un jeu personnalisé",
|
||||||
|
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
|
||||||
|
"custom_game_modal_executable_path": "Chemin de l'exécutable",
|
||||||
|
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
|
||||||
|
"custom_game_modal_title": "Titre",
|
||||||
|
"custom_game_modal_enter_title": "Entrer le titre",
|
||||||
|
"custom_game_modal_browse": "Parcourir",
|
||||||
|
"custom_game_modal_cancel": "Annuler",
|
||||||
|
"custom_game_modal_add": "Ajouter le jeu",
|
||||||
|
"custom_game_modal_adding": "Ajout du jeu…",
|
||||||
|
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
|
||||||
|
"custom_game_modal_failed": "Échec de l’ajout du jeu personnalisé",
|
||||||
|
"custom_game_modal_executable": "Exécutable",
|
||||||
|
"edit_game_modal": "Personnaliser les ressources",
|
||||||
|
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
|
||||||
|
"edit_game_modal_title": "Titre",
|
||||||
|
"edit_game_modal_enter_title": "Entrer le titre",
|
||||||
|
"edit_game_modal_image": "Image",
|
||||||
|
"edit_game_modal_select_image": "Sélectionner une image",
|
||||||
|
"edit_game_modal_browse": "Parcourir",
|
||||||
|
"edit_game_modal_image_preview": "Aperçu de l’image",
|
||||||
|
"edit_game_modal_icon": "Icône",
|
||||||
|
"edit_game_modal_select_icon": "Sélectionner une icône",
|
||||||
|
"edit_game_modal_icon_preview": "Aperçu de l’icône",
|
||||||
|
"edit_game_modal_logo": "Logo",
|
||||||
|
"edit_game_modal_select_logo": "Sélectionner un logo",
|
||||||
|
"edit_game_modal_logo_preview": "Aperçu du logo",
|
||||||
|
"edit_game_modal_hero": "Bannière de la bibliothèque",
|
||||||
|
"edit_game_modal_select_hero": "Sélectionner l’image de bannière",
|
||||||
|
"edit_game_modal_hero_preview": "Aperçu de la bannière",
|
||||||
|
"edit_game_modal_cancel": "Annuler",
|
||||||
|
"edit_game_modal_update": "Mettre à jour",
|
||||||
|
"edit_game_modal_updating": "Mise à jour…",
|
||||||
|
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
|
||||||
|
"edit_game_modal_success": "Ressources mises à jour avec succès",
|
||||||
|
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
|
||||||
|
"edit_game_modal_image_filter": "Image",
|
||||||
|
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
|
||||||
|
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
|
||||||
|
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
|
||||||
|
"edit_game_modal_assets": "Ressources",
|
||||||
|
"edit_game_modal_drop_icon_image_here": "Déposez l’image de l’icône ici",
|
||||||
|
"edit_game_modal_drop_logo_image_here": "Déposez l’image du logo ici",
|
||||||
|
"edit_game_modal_drop_hero_image_here": "Déposez l’image de la bannière ici",
|
||||||
|
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer l’icône",
|
||||||
|
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
|
||||||
|
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
|
||||||
|
"install_decky_plugin": "Installer le plugin Decky",
|
||||||
|
"update_decky_plugin": "Mettre à jour le plugin Decky",
|
||||||
|
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
|
||||||
|
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
|
||||||
|
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
|
||||||
|
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
|
||||||
|
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
|
||||||
|
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
|
||||||
|
"decky_plugin_installation_failed": "Échec de l’installation du plugin Decky : {{error}}",
|
||||||
|
"decky_plugin_installation_error": "Erreur lors de l’installation du plugin Decky : {{error}}",
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"cancel": "Annuler"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
@@ -37,7 +99,15 @@
|
|||||||
"search_results": "Résultats de la recherche",
|
"search_results": "Résultats de la recherche",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
|
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
|
||||||
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
|
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.",
|
||||||
|
"search_library": "Rechercher dans la bibliothèque",
|
||||||
|
"recent_searches": "Recherches récentes",
|
||||||
|
"suggestions": "Suggestions",
|
||||||
|
"clear_history": "Effacer",
|
||||||
|
"remove_from_history": "Supprimer de l'historique",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"no_results": "Aucun résultat",
|
||||||
|
"library": "Bibliothèque"
|
||||||
},
|
},
|
||||||
"bottom_panel": {
|
"bottom_panel": {
|
||||||
"no_downloads_in_progress": "Aucun téléchargement en cours",
|
"no_downloads_in_progress": "Aucun téléchargement en cours",
|
||||||
@@ -47,7 +117,8 @@
|
|||||||
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
|
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Installation terminée",
|
"installation_complete": "Installation terminée",
|
||||||
"installation_complete_message": "Redistribuables communs installés avec succès"
|
"installation_complete_message": "Redistribuables communs installés avec succès",
|
||||||
|
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
|
||||||
},
|
},
|
||||||
"catalogue": {
|
"catalogue": {
|
||||||
"search": "Filtrer…",
|
"search": "Filtrer…",
|
||||||
@@ -198,7 +269,113 @@
|
|||||||
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
|
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
|
||||||
"game_removed_from_favorites": "Jeu retiré des favoris",
|
"game_removed_from_favorites": "Jeu retiré des favoris",
|
||||||
"game_added_to_favorites": "Jeu ajouté aux favoris",
|
"game_added_to_favorites": "Jeu ajouté aux favoris",
|
||||||
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
|
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés",
|
||||||
|
"already_in_library": "Déjà dans la bibliothèque",
|
||||||
|
"create_shortcut_simple": "Créer un raccourci",
|
||||||
|
"properties": "Propriétés",
|
||||||
|
"extracting": "Extraction en cours",
|
||||||
|
"new_download_option": "Nouveau",
|
||||||
|
"create_steam_shortcut": "Créer un raccourci Steam",
|
||||||
|
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
|
||||||
|
"add_to_favorites": "Ajouter aux favoris",
|
||||||
|
"remove_from_favorites": "Retirer des favoris",
|
||||||
|
"failed_update_favorites": "Échec de la mise à jour des favoris",
|
||||||
|
"game_removed_from_library": "Jeu retiré de la bibliothèque",
|
||||||
|
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
|
||||||
|
"files_removed_success": "Fichiers supprimés avec succès",
|
||||||
|
"failed_remove_files": "Échec de la suppression des fichiers",
|
||||||
|
"rating_count": "Évaluations",
|
||||||
|
"show_more": "Afficher plus",
|
||||||
|
"show_less": "Afficher moins",
|
||||||
|
"reviews": "Avis",
|
||||||
|
"review_played_for": "Temps de jeu",
|
||||||
|
"leave_a_review": "Laisser un avis",
|
||||||
|
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
|
||||||
|
"sort_newest": "Les plus récents",
|
||||||
|
"sort_oldest": "Les plus anciens",
|
||||||
|
"sort_highest_score": "Meilleure note",
|
||||||
|
"sort_lowest_score": "Note la plus basse",
|
||||||
|
"sort_most_voted": "Les plus votés",
|
||||||
|
"no_reviews_yet": "Aucun avis pour le moment",
|
||||||
|
"be_first_to_review": "Soyez le premier à donner votre avis !",
|
||||||
|
"rating": "Note",
|
||||||
|
"rating_stats": "Évaluation",
|
||||||
|
"rating_very_negative": "Très négatif",
|
||||||
|
"rating_negative": "Négatif",
|
||||||
|
"rating_neutral": "Neutre",
|
||||||
|
"rating_positive": "Positif",
|
||||||
|
"rating_very_positive": "Très positif",
|
||||||
|
"submit_review": "Envoyer",
|
||||||
|
"submitting": "Envoi…",
|
||||||
|
"review_submitted_successfully": "Avis envoyé avec succès !",
|
||||||
|
"review_submission_failed": "Échec de l’envoi de l’avis. Veuillez réessayer.",
|
||||||
|
"review_cannot_be_empty": "Le champ de l’avis ne peut pas être vide.",
|
||||||
|
"review_deleted_successfully": "Avis supprimé avec succès.",
|
||||||
|
"review_deletion_failed": "Échec de la suppression de l’avis.",
|
||||||
|
"loading_reviews": "Chargement des avis…",
|
||||||
|
"loading_more_reviews": "Chargement de plus d’avis…",
|
||||||
|
"load_more_reviews": "Charger plus d’avis",
|
||||||
|
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
|
||||||
|
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
|
||||||
|
"yes": "Oui",
|
||||||
|
"maybe_later": "Peut-être plus tard",
|
||||||
|
"backup_failed": "Échec de la sauvegarde",
|
||||||
|
"update_playtime_title": "Mettre à jour le temps de jeu",
|
||||||
|
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
|
||||||
|
"update_playtime": "Mettre à jour le temps de jeu",
|
||||||
|
"update_playtime_success": "Temps de jeu mis à jour avec succès",
|
||||||
|
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
|
||||||
|
"update_game_playtime": "Mettre à jour le temps de jeu",
|
||||||
|
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
|
||||||
|
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
|
||||||
|
"game_removed_from_pinned": "Jeu retiré des épinglés",
|
||||||
|
"game_added_to_pinned": "Jeu ajouté aux épinglés",
|
||||||
|
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
|
||||||
|
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
|
||||||
|
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
|
||||||
|
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
|
||||||
|
"artifact_renamed": "Sauvegarde renommée avec succès",
|
||||||
|
"rename_artifact": "Renommer la sauvegarde",
|
||||||
|
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
|
||||||
|
"artifact_name_label": "Nom de la sauvegarde",
|
||||||
|
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
|
||||||
|
"save_changes": "Enregistrer les modifications",
|
||||||
|
"required_field": "Ce champ est requis",
|
||||||
|
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
|
||||||
|
"freeze_backup": "Épingler pour éviter l’écrasement automatique",
|
||||||
|
"unfreeze_backup": "Désépingler",
|
||||||
|
"backup_frozen": "Sauvegarde épinglée",
|
||||||
|
"backup_unfrozen": "Sauvegarde désépinglée",
|
||||||
|
"backup_freeze_failed": "Échec de l’épinglage de la sauvegarde",
|
||||||
|
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
|
||||||
|
"edit_game_modal_button": "Personnaliser les ressources du jeu",
|
||||||
|
"game_details": "Détails du jeu",
|
||||||
|
"prices": "Prix",
|
||||||
|
"no_prices_found": "Aucun prix trouvé",
|
||||||
|
"view_all_prices": "Cliquer pour voir tous les prix",
|
||||||
|
"retail_price": "Prix officiel",
|
||||||
|
"keyshop_price": "Prix Keyshop",
|
||||||
|
"historical_retail": "Historique officiel",
|
||||||
|
"historical_keyshop": "Historique Keyshop",
|
||||||
|
"language": "Langue",
|
||||||
|
"caption": "Sous-titres",
|
||||||
|
"audio": "Audio",
|
||||||
|
"filter_by_source": "Filtrer par source",
|
||||||
|
"no_repacks_found": "Aucune source trouvée pour ce jeu",
|
||||||
|
"delete_review": "Supprimer l’avis",
|
||||||
|
"remove_review": "Retirer l’avis",
|
||||||
|
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
|
||||||
|
"delete_review_modal_description": "Cette action est irréversible.",
|
||||||
|
"delete_review_modal_delete_button": "Supprimer",
|
||||||
|
"delete_review_modal_cancel_button": "Annuler",
|
||||||
|
"vote_failed": "Échec de l’enregistrement de votre vote. Veuillez réessayer.",
|
||||||
|
"show_original": "Afficher l’original",
|
||||||
|
"show_translation": "Afficher la traduction",
|
||||||
|
"show_original_translated_from": "Afficher l’original (traduit depuis {{language}})",
|
||||||
|
"hide_original": "Masquer l’original",
|
||||||
|
"review_from_blocked_user": "Avis d’un utilisateur bloqué",
|
||||||
|
"show": "Afficher",
|
||||||
|
"hide": "Masquer"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activer Hydra",
|
"title": "Activer Hydra",
|
||||||
@@ -237,7 +414,11 @@
|
|||||||
"resume_seeding": "Reprendre le partage",
|
"resume_seeding": "Reprendre le partage",
|
||||||
"options": "Gérer",
|
"options": "Gérer",
|
||||||
"extract": "Extraire les fichiers",
|
"extract": "Extraire les fichiers",
|
||||||
"extracting": "Extraction des fichiers…"
|
"extracting": "Extraction des fichiers…",
|
||||||
|
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
|
||||||
|
"delete_archive_description": "Le fichier a été extrait avec succès et n’est plus nécessaire.",
|
||||||
|
"yes": "Oui",
|
||||||
|
"no": "Non"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Chemin des téléchargements",
|
"downloads_path": "Chemin des téléchargements",
|
||||||
@@ -366,7 +547,40 @@
|
|||||||
"bottom-left": "En bas à gauche",
|
"bottom-left": "En bas à gauche",
|
||||||
"bottom-center": "En bas au centre",
|
"bottom-center": "En bas au centre",
|
||||||
"bottom-right": "En bas à droite",
|
"bottom-right": "En bas à droite",
|
||||||
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
|
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu",
|
||||||
|
"adding": "Ajout…",
|
||||||
|
"failed_add_download_source": "Échec de l’ajout de la source de téléchargement. Veuillez réessayer.",
|
||||||
|
"download_source_already_exists": "Cette URL de source existe déjà",
|
||||||
|
"download_source_pending_matching": "Mise à jour imminente",
|
||||||
|
"download_source_matched": "À jour",
|
||||||
|
"download_source_matching": "Mise à jour",
|
||||||
|
"download_source_failed": "Erreur",
|
||||||
|
"download_source_no_information": "Aucune information disponible",
|
||||||
|
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
|
||||||
|
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
|
||||||
|
"importing": "Importation…",
|
||||||
|
"hydra_cloud": "Hydra Cloud",
|
||||||
|
"debrid": "Debrid",
|
||||||
|
"enable_steam_achievements": "Activer la recherche de succès Steam",
|
||||||
|
"alignment": "Alignement",
|
||||||
|
"variation": "Variation",
|
||||||
|
"default": "Par défaut",
|
||||||
|
"rare": "Rare",
|
||||||
|
"platinum": "Platine",
|
||||||
|
"hidden": "Caché",
|
||||||
|
"test_notification": "Notification de test",
|
||||||
|
"achievement_sound_volume": "Volume du son de succès",
|
||||||
|
"select_achievement_sound": "Sélectionner un son de succès",
|
||||||
|
"change_achievement_sound": "Changer le son de succès",
|
||||||
|
"remove_achievement_sound": "Supprimer le son de succès",
|
||||||
|
"preview_sound": "Prévisualiser le son",
|
||||||
|
"select": "Sélectionner",
|
||||||
|
"preview": "Aperçu",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
|
||||||
|
"notification_preview": "Aperçu de la notification de succès",
|
||||||
|
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
|
||||||
|
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement d’un jeu"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Téléchargement terminé",
|
"download_complete": "Téléchargement terminé",
|
||||||
|
|||||||
@@ -22,10 +22,11 @@
|
|||||||
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
||||||
"filter": "Könyvtár szűrése",
|
"filter": "Könyvtár szűrése",
|
||||||
"home": "Főoldal",
|
"home": "Főoldal",
|
||||||
"queued": "A(z) {{title}} (Várakozósorban van)",
|
"queued": "{{title}} (Várakozásban)",
|
||||||
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
||||||
"sign_in": "Bejelentkezés",
|
"sign_in": "Bejelentkezés",
|
||||||
"friends": "Barátok",
|
"friends": "Barátok",
|
||||||
|
"notifications": "Értesítések",
|
||||||
"need_help": "Elakadtál?",
|
"need_help": "Elakadtál?",
|
||||||
"favorites": "Kedvenc Játékaim",
|
"favorites": "Kedvenc Játékaim",
|
||||||
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
|
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
|
||||||
@@ -94,6 +95,12 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"search": "Keresés",
|
"search": "Keresés",
|
||||||
"search_library": "Könyvtár böngészése",
|
"search_library": "Könyvtár böngészése",
|
||||||
|
"recent_searches": "Korábbi Keresések",
|
||||||
|
"suggestions": "Találatok",
|
||||||
|
"clear_history": "Törlés",
|
||||||
|
"remove_from_history": "Törlés az előzményekből",
|
||||||
|
"loading": "Töltés...",
|
||||||
|
"no_results": "Nincs találat",
|
||||||
"home": "Főoldal",
|
"home": "Főoldal",
|
||||||
"catalogue": "Katalógus",
|
"catalogue": "Katalógus",
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
@@ -109,6 +116,7 @@
|
|||||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
||||||
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
||||||
|
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Telepítés befejezve",
|
"installation_complete": "Telepítés befejezve",
|
||||||
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
||||||
@@ -165,8 +173,9 @@
|
|||||||
"playing_now": "Játékban: ",
|
"playing_now": "Játékban: ",
|
||||||
"change": "Változtatás",
|
"change": "Változtatás",
|
||||||
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
||||||
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
|
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
|
||||||
"download_now": "Letöltés",
|
"download_now": "Letöltés",
|
||||||
|
"loading": "Töltés...",
|
||||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||||
"download_options": "Letöltési opciók",
|
"download_options": "Letöltési opciók",
|
||||||
"download_path": "Letöltési hely",
|
"download_path": "Letöltési hely",
|
||||||
@@ -175,7 +184,13 @@
|
|||||||
"screenshot": "Screenshot {{number}}",
|
"screenshot": "Screenshot {{number}}",
|
||||||
"open_screenshot": "{{number}} Screenshot megnyitása ",
|
"open_screenshot": "{{number}} Screenshot megnyitása ",
|
||||||
"download_settings": "Letöltési beállítások",
|
"download_settings": "Letöltési beállítások",
|
||||||
"downloader": "Letöltési mód",
|
"downloader": "Letöltő",
|
||||||
|
"downloader_online": "Elérhető",
|
||||||
|
"downloader_not_configured": "Elérhető de nincs beállítva",
|
||||||
|
"downloader_offline": "A link nem elérhető",
|
||||||
|
"downloader_not_available": "Nem elérhető",
|
||||||
|
"recommended": "Ajánlott",
|
||||||
|
"go_to_settings": "Beállítások megnyitása",
|
||||||
"select_executable": "Tallózás",
|
"select_executable": "Tallózás",
|
||||||
"no_executable_selected": "Nincs futtatható fájl tallózva",
|
"no_executable_selected": "Nincs futtatható fájl tallózva",
|
||||||
"open_folder": "Mappa megnyitása",
|
"open_folder": "Mappa megnyitása",
|
||||||
@@ -196,6 +211,7 @@
|
|||||||
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
||||||
"download_in_progress": "Letöltés folyamatban",
|
"download_in_progress": "Letöltés folyamatban",
|
||||||
"download_paused": "Letöltés szüneteltetve",
|
"download_paused": "Letöltés szüneteltetve",
|
||||||
|
"extracting": "Kicsomagolás",
|
||||||
"last_downloaded_option": "Utoljára letöltött",
|
"last_downloaded_option": "Utoljára letöltött",
|
||||||
"new_download_option": "Új",
|
"new_download_option": "Új",
|
||||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||||
@@ -397,7 +413,7 @@
|
|||||||
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
||||||
"install": "Telepít",
|
"install": "Telepít",
|
||||||
"download_in_progress": "Folyamatban lévő",
|
"download_in_progress": "Folyamatban lévő",
|
||||||
"queued_downloads": "Várakozósoron lévő letöltések",
|
"queued_downloads": "Várakozásban lévő letöltések",
|
||||||
"downloads_completed": "Befejezett",
|
"downloads_completed": "Befejezett",
|
||||||
"queued": "Várakozásban",
|
"queued": "Várakozásban",
|
||||||
"no_downloads_title": "Oly üres..",
|
"no_downloads_title": "Oly üres..",
|
||||||
@@ -408,7 +424,13 @@
|
|||||||
"resume_seeding": "Seedelés folytatása",
|
"resume_seeding": "Seedelés folytatása",
|
||||||
"options": "Kezelés",
|
"options": "Kezelés",
|
||||||
"extract": "Fájlok kibontása",
|
"extract": "Fájlok kibontása",
|
||||||
"extracting": "Fájlok kibontása…"
|
"extracting": "Fájlok kibontása…",
|
||||||
|
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
|
||||||
|
"delete_archive_description": "A tömörített fájl ki lett csomagolva és többé nincs rá szükség.",
|
||||||
|
"yes": "Igen",
|
||||||
|
"no": "Nem",
|
||||||
|
"network": "HÁLÓZAT",
|
||||||
|
"peak": "CSÚCS"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Letöltési útvonalak",
|
"downloads_path": "Letöltési útvonalak",
|
||||||
@@ -432,7 +454,7 @@
|
|||||||
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
|
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
|
||||||
"save_changes": "Változtatások mentése",
|
"save_changes": "Változtatások mentése",
|
||||||
"changes_saved": "Változtatások sikeresen mentve",
|
"changes_saved": "Változtatások sikeresen mentve",
|
||||||
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
|
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból, ennek az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
|
||||||
"validate_download_source": "Érvényesítés",
|
"validate_download_source": "Érvényesítés",
|
||||||
"remove_download_source": "Eltávolítás",
|
"remove_download_source": "Eltávolítás",
|
||||||
"add_download_source": "Forrás hozáadása",
|
"add_download_source": "Forrás hozáadása",
|
||||||
@@ -544,6 +566,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
|
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
|
||||||
"extract_files_by_default": "Fájlok kicsomagolása letöltés után",
|
"extract_files_by_default": "Fájlok kicsomagolása letöltés után",
|
||||||
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
|
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
|
||||||
|
"enable_new_download_options_badges": "Új letöltési helyek",
|
||||||
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
|
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
|
||||||
"top-left": "Bal felső sarok",
|
"top-left": "Bal felső sarok",
|
||||||
"top-center": "Felső közép",
|
"top-center": "Felső közép",
|
||||||
@@ -624,9 +647,9 @@
|
|||||||
"sort_by": "Rendezés:",
|
"sort_by": "Rendezés:",
|
||||||
"achievements_earned": "Elért achievementek",
|
"achievements_earned": "Elért achievementek",
|
||||||
"played_recently": "Nemrég játszva",
|
"played_recently": "Nemrég játszva",
|
||||||
"playtime": "Játszottidő",
|
"playtime": "Játékidő",
|
||||||
"total_play_time": "Teljes játszottidő",
|
"total_play_time": "Teljes játékidő",
|
||||||
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
|
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
|
||||||
"no_recent_activity_title": "Hmmm… itt semmi sincs",
|
"no_recent_activity_title": "Hmmm… itt semmi sincs",
|
||||||
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
|
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
|
||||||
"display_name": "Profilnév",
|
"display_name": "Profilnév",
|
||||||
@@ -648,6 +671,7 @@
|
|||||||
"sending": "Küldés..",
|
"sending": "Küldés..",
|
||||||
"friend_request_sent": "Barátfelkérés elküldve",
|
"friend_request_sent": "Barátfelkérés elküldve",
|
||||||
"friends": "Barátok",
|
"friends": "Barátok",
|
||||||
|
"badges": "Kitűzők",
|
||||||
"friends_list": "Barát lista",
|
"friends_list": "Barát lista",
|
||||||
"user_not_found": "Felhasználó nem találva",
|
"user_not_found": "Felhasználó nem találva",
|
||||||
"block_user": "Felhasználó letiltása",
|
"block_user": "Felhasználó letiltása",
|
||||||
@@ -658,18 +682,22 @@
|
|||||||
"ignore_request": "Kérés ignorálása",
|
"ignore_request": "Kérés ignorálása",
|
||||||
"cancel_request": "Kérés visszavonása",
|
"cancel_request": "Kérés visszavonása",
|
||||||
"undo_friendship": "Barát eltávolítása",
|
"undo_friendship": "Barát eltávolítása",
|
||||||
|
"friendship_removed": "Barát eltávolítva",
|
||||||
"request_accepted": "Barátfelkérés elfogadva",
|
"request_accepted": "Barátfelkérés elfogadva",
|
||||||
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
|
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
|
||||||
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
|
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
|
||||||
"blocked_users": "Letiltott felhasználók",
|
"blocked_users": "Letiltott felhasználók",
|
||||||
"unblock": "Tiltás feloldása",
|
"unblock": "Tiltás feloldása",
|
||||||
"no_friends_added": "Nincs bejelölt barátod",
|
"no_friends_added": "Nincs bejelölt barátod",
|
||||||
|
"view_all": "Összes megtekintése",
|
||||||
|
"load_more": "Több betöltése",
|
||||||
|
"loading": "Töltés..",
|
||||||
"pending": "Függőben",
|
"pending": "Függőben",
|
||||||
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
|
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
|
||||||
"no_blocked_users": "Nincs letiltott felhasználó",
|
"no_blocked_users": "Nincs letiltott felhasználó",
|
||||||
"friend_code_copied": "Barát kód kimásolva",
|
"friend_code_copied": "Barát kód kimásolva",
|
||||||
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
||||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
|
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
|
||||||
"locked_profile": "Ez a profil privát",
|
"locked_profile": "Ez a profil privát",
|
||||||
"image_process_failure": "Hiba a kép feldolgozása közben",
|
"image_process_failure": "Hiba a kép feldolgozása közben",
|
||||||
"required_field": "Ez a mező kötelező",
|
"required_field": "Ez a mező kötelező",
|
||||||
@@ -687,6 +715,7 @@
|
|||||||
"report_reason_other": "Egyéb",
|
"report_reason_other": "Egyéb",
|
||||||
"profile_reported": "Profil bejelentve",
|
"profile_reported": "Profil bejelentve",
|
||||||
"your_friend_code": "A barát kódod:",
|
"your_friend_code": "A barát kódod:",
|
||||||
|
"copy_friend_code": "Barátkód kimásolása",
|
||||||
"upload_banner": "Borítókép feltöltése",
|
"upload_banner": "Borítókép feltöltése",
|
||||||
"uploading_banner": "Borítókép feltöltése…",
|
"uploading_banner": "Borítókép feltöltése…",
|
||||||
"background_image_updated": "Borítókép frissítve",
|
"background_image_updated": "Borítókép frissítve",
|
||||||
@@ -706,10 +735,12 @@
|
|||||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
||||||
"karma": "Karma",
|
"karma": "Karma",
|
||||||
"karma_count": "karma",
|
"karma_count": "karma",
|
||||||
"karma_description": "Pozitív értékelésekkel szerzett pontok",
|
|
||||||
"user_reviews": "Vélemények",
|
"user_reviews": "Vélemények",
|
||||||
"delete_review": "Vélemény Törlése",
|
"delete_review": "Vélemény Törlése",
|
||||||
"loading_reviews": "Vélemények betöltése..."
|
"loading_reviews": "Vélemények betöltése...",
|
||||||
|
"wrapped_2025": "Wrapped 2025",
|
||||||
|
"view_my_wrapped_button": "Wrapped 2025 megtekintése",
|
||||||
|
"view_wrapped_button": "{{displayName}} Wrapped 2025 megtekintése"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
@@ -727,7 +758,7 @@
|
|||||||
"amount_minutes": "{{amount}} perc",
|
"amount_minutes": "{{amount}} perc",
|
||||||
"amount_hours_short": "{{amount}}ó",
|
"amount_hours_short": "{{amount}}ó",
|
||||||
"amount_minutes_short": "{{amount}}p",
|
"amount_minutes_short": "{{amount}}p",
|
||||||
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
|
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
|
||||||
"all_games": "Összes Játék",
|
"all_games": "Összes Játék",
|
||||||
"recently_played": "Nemrég Játszva",
|
"recently_played": "Nemrég Játszva",
|
||||||
"favorites": "Kedvencek"
|
"favorites": "Kedvencek"
|
||||||
@@ -760,5 +791,41 @@
|
|||||||
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
|
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
|
||||||
"learn_more": "Tudj meg többet",
|
"learn_more": "Tudj meg többet",
|
||||||
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
|
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Értesítések",
|
||||||
|
"mark_all_as_read": "Megjelölés olvasottként",
|
||||||
|
"clear_all": "Összes Törlése",
|
||||||
|
"loading": "Töltés..",
|
||||||
|
"empty_title": "Nincsenek értesítések",
|
||||||
|
"empty_description": "Már mindet láttad! Nézz vissza később az újdonságokért.",
|
||||||
|
"empty_filter_description": "Nincs értesítés ami megfelel ennek a szűrőnek.",
|
||||||
|
"filter_all": "Összes",
|
||||||
|
"filter_unread": "Olvasatlan",
|
||||||
|
"filter_friends": "Barátok",
|
||||||
|
"filter_badges": "Kitűzők",
|
||||||
|
"filter_upvotes": "Felpontok",
|
||||||
|
"filter_local": "Helyi",
|
||||||
|
"load_more": "Több betöltése",
|
||||||
|
"dismiss": "Eltüntetés",
|
||||||
|
"accept": "Elfogad",
|
||||||
|
"refuse": "Elutasít",
|
||||||
|
"notification": "Értesítés",
|
||||||
|
"friend_request_received_title": "Új barátkérelem!",
|
||||||
|
"friend_request_received_description": "{{displayName}} a barátod szeretne lenni",
|
||||||
|
"friend_request_accepted_title": "Barátkérelem elfogadva!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} elfogadta a barátkérelmed",
|
||||||
|
"badge_received_title": "Kaptál egy új kitűzőt!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "A véleményed a(z) {{gameTitle}} játékhoz felpont-ot kapott!",
|
||||||
|
"review_upvote_description": "A véleményed {{count}} új felpontot kapott",
|
||||||
|
"marked_all_as_read": "Összes értesítés olvasottnak jelölve",
|
||||||
|
"failed_to_mark_as_read": "Az értesítések olvasottnak jelölése nem sikerült",
|
||||||
|
"cleared_all": "Összes értesítés eltüntetve",
|
||||||
|
"failed_to_clear": "Az értesítések eltüntetése nem sikerült",
|
||||||
|
"failed_to_load": "Az értesítések betöltése nem sikerült",
|
||||||
|
"failed_to_dismiss": "Értesítés eltüntetése nem sikerült",
|
||||||
|
"friend_request_accepted": "Barátfelkérés elfogadva",
|
||||||
|
"friend_request_refused": "Barátfelkérés elutasítva"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -93,11 +94,19 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Buscar jogos",
|
"search": "Buscar jogos",
|
||||||
|
"search_library": "Buscar na biblioteca",
|
||||||
|
"recent_searches": "Buscas Recentes",
|
||||||
|
"suggestions": "Sugestões",
|
||||||
|
"clear_history": "Limpar",
|
||||||
|
"remove_from_history": "Remover do histórico",
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"no_results": "Sem resultados",
|
||||||
|
"home": "Início",
|
||||||
"catalogue": "Catálogo",
|
"catalogue": "Catálogo",
|
||||||
|
"library": "Biblioteca",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"search_results": "Resultados da busca",
|
"search_results": "Resultados da busca",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"home": "Início",
|
|
||||||
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
|
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
|
||||||
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
|
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
|
||||||
},
|
},
|
||||||
@@ -107,6 +116,7 @@
|
|||||||
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
|
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
|
||||||
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
|
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
|
||||||
"checking_files": "Verificando arquivos de {{title}}…",
|
"checking_files": "Verificando arquivos de {{title}}…",
|
||||||
|
"extracting": "Extraindo {{title}}… ({{percentage}} concluído)",
|
||||||
"installing_common_redist": "{{log}}…",
|
"installing_common_redist": "{{log}}…",
|
||||||
"installation_complete": "Instalação concluída",
|
"installation_complete": "Instalação concluída",
|
||||||
"installation_complete_message": "Componentes recomendados instalados com sucesso"
|
"installation_complete_message": "Componentes recomendados instalados com sucesso"
|
||||||
@@ -154,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",
|
||||||
@@ -163,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",
|
||||||
@@ -182,6 +199,7 @@
|
|||||||
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
|
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
|
||||||
"download_in_progress": "Download em andamento",
|
"download_in_progress": "Download em andamento",
|
||||||
"download_paused": "Download pausado",
|
"download_paused": "Download pausado",
|
||||||
|
"extracting": "Extraindo",
|
||||||
"last_downloaded_option": "Última opção baixada",
|
"last_downloaded_option": "Última opção baixada",
|
||||||
"new_download_option": "Novo",
|
"new_download_option": "Novo",
|
||||||
"create_steam_shortcut": "Criar atalho na Steam",
|
"create_steam_shortcut": "Criar atalho na Steam",
|
||||||
@@ -352,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",
|
||||||
@@ -374,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…",
|
||||||
@@ -394,7 +417,13 @@
|
|||||||
"resume_seeding": "Semear",
|
"resume_seeding": "Semear",
|
||||||
"options": "Gerenciar",
|
"options": "Gerenciar",
|
||||||
"extract": "Extrair arquivos",
|
"extract": "Extrair arquivos",
|
||||||
"extracting": "Extraindo arquivos…"
|
"extracting": "Extraindo arquivos…",
|
||||||
|
"delete_archive_title": "Deseja deletar {{fileName}}?",
|
||||||
|
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
|
||||||
|
"yes": "Sim",
|
||||||
|
"no": "Não",
|
||||||
|
"network": "REDE",
|
||||||
|
"peak": "PICO"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Diretório dos downloads",
|
"downloads_path": "Diretório dos downloads",
|
||||||
@@ -441,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",
|
||||||
@@ -466,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",
|
||||||
@@ -528,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",
|
||||||
@@ -545,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",
|
||||||
@@ -552,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",
|
||||||
@@ -638,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",
|
||||||
@@ -650,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",
|
||||||
@@ -679,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",
|
||||||
@@ -704,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"
|
||||||
},
|
},
|
||||||
@@ -760,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,19 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Procurar jogos",
|
"search": "Procurar jogos",
|
||||||
|
"search_library": "Procurar na biblioteca",
|
||||||
|
"recent_searches": "Pesquisas Recentes",
|
||||||
|
"suggestions": "Sugestões",
|
||||||
|
"clear_history": "Limpar",
|
||||||
|
"remove_from_history": "Remover do histórico",
|
||||||
|
"loading": "A carregar...",
|
||||||
|
"no_results": "Sem resultados",
|
||||||
|
"home": "Início",
|
||||||
"catalogue": "Catálogo",
|
"catalogue": "Catálogo",
|
||||||
|
"library": "Biblioteca",
|
||||||
"downloads": "Transferências",
|
"downloads": "Transferências",
|
||||||
"search_results": "Resultados da pesquisa",
|
"search_results": "Resultados da pesquisa",
|
||||||
"settings": "Definições",
|
"settings": "Definições",
|
||||||
"home": "Início",
|
|
||||||
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
|
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
|
||||||
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
|
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
|
||||||
},
|
},
|
||||||
@@ -500,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Показать только установленные игры.",
|
||||||
@@ -93,8 +94,16 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
|
"search_library": "Поиск в библиотеке",
|
||||||
|
"recent_searches": "Недавние поиски",
|
||||||
|
"suggestions": "Предложения",
|
||||||
|
"clear_history": "Очистить",
|
||||||
|
"remove_from_history": "Удалить из истории",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"no_results": "Нет результатов",
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"catalogue": "Каталог",
|
"catalogue": "Каталог",
|
||||||
|
"library": "Библиотека",
|
||||||
"downloads": "Загрузки",
|
"downloads": "Загрузки",
|
||||||
"search_results": "Результаты поиска",
|
"search_results": "Результаты поиска",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
@@ -107,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": "Библиотеки успешно установлены"
|
||||||
@@ -165,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": "Путь для загрузок",
|
||||||
@@ -174,6 +185,12 @@
|
|||||||
"open_screenshot": "Открыть скриншот {{number}}",
|
"open_screenshot": "Открыть скриншот {{number}}",
|
||||||
"download_settings": "Параметры загрузки",
|
"download_settings": "Параметры загрузки",
|
||||||
"downloader": "Загрузчик",
|
"downloader": "Загрузчик",
|
||||||
|
"downloader_online": "Онлайн",
|
||||||
|
"downloader_not_configured": "Доступен, но не настроен",
|
||||||
|
"downloader_offline": "Ссылка недоступна",
|
||||||
|
"downloader_not_available": "Недоступно",
|
||||||
|
"recommended": "Рекомендуется",
|
||||||
|
"go_to_settings": "Перейти в настройки",
|
||||||
"select_executable": "Выбрать",
|
"select_executable": "Выбрать",
|
||||||
"no_executable_selected": "Файл не выбран",
|
"no_executable_selected": "Файл не выбран",
|
||||||
"open_folder": "Открыть папку",
|
"open_folder": "Открыть папку",
|
||||||
@@ -194,6 +211,7 @@
|
|||||||
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
|
"danger_zone_section_description": "Вы можете удалить эту игру из вашей библиотеки или файлы скачанные из Hydra",
|
||||||
"download_in_progress": "Идёт загрузка",
|
"download_in_progress": "Идёт загрузка",
|
||||||
"download_paused": "Загрузка приостановлена",
|
"download_paused": "Загрузка приостановлена",
|
||||||
|
"extracting": "Распаковка",
|
||||||
"last_downloaded_option": "Последний вариант загрузки",
|
"last_downloaded_option": "Последний вариант загрузки",
|
||||||
"new_download_option": "Новый",
|
"new_download_option": "Новый",
|
||||||
"create_steam_shortcut": "Создать ярлык Steam",
|
"create_steam_shortcut": "Создать ярлык Steam",
|
||||||
@@ -386,6 +404,10 @@
|
|||||||
"completed": "Завершено",
|
"completed": "Завершено",
|
||||||
"removed": "Не скачано",
|
"removed": "Не скачано",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
|
"cancel_download": "Отменить загрузку?",
|
||||||
|
"cancel_download_description": "Вы уверены, что хотите отменить эту загрузку? Все загруженные файлы будут удалены.",
|
||||||
|
"keep_downloading": "Нет, продолжить загрузку",
|
||||||
|
"yes_cancel": "Да, отменить",
|
||||||
"filter": "Поиск загруженных игр",
|
"filter": "Поиск загруженных игр",
|
||||||
"remove": "Удалить",
|
"remove": "Удалить",
|
||||||
"downloading_metadata": "Загрузка метаданных…",
|
"downloading_metadata": "Загрузка метаданных…",
|
||||||
@@ -406,7 +428,13 @@
|
|||||||
"resume_seeding": "Продолжить раздачу",
|
"resume_seeding": "Продолжить раздачу",
|
||||||
"options": "Управлять",
|
"options": "Управлять",
|
||||||
"extract": "Распаковать файлы",
|
"extract": "Распаковать файлы",
|
||||||
"extracting": "Распаковка файлов…"
|
"extracting": "Распаковка файлов…",
|
||||||
|
"delete_archive_title": "Хотите удалить {{fileName}}?",
|
||||||
|
"delete_archive_description": "Файл был успешно распакован и больше не нужен.",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"network": "СЕТЬ",
|
||||||
|
"peak": "ПИК"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Путь загрузок",
|
"downloads_path": "Путь загрузок",
|
||||||
@@ -542,6 +570,7 @@
|
|||||||
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
|
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
|
||||||
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
|
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
|
||||||
"enable_steam_achievements": "Включить поиск достижений Steam",
|
"enable_steam_achievements": "Включить поиск достижений Steam",
|
||||||
|
"enable_new_download_options_badges": "Показывать значки новых вариантов загрузки",
|
||||||
"achievement_custom_notification_position": "Позиция уведомлений достижений",
|
"achievement_custom_notification_position": "Позиция уведомлений достижений",
|
||||||
"top-left": "Верхний левый угол",
|
"top-left": "Верхний левый угол",
|
||||||
"top-center": "Верхний центр",
|
"top-center": "Верхний центр",
|
||||||
@@ -559,6 +588,9 @@
|
|||||||
"test_notification": "Тестовое уведомление",
|
"test_notification": "Тестовое уведомление",
|
||||||
"achievement_sound_volume": "Громкость звука достижения",
|
"achievement_sound_volume": "Громкость звука достижения",
|
||||||
"select_achievement_sound": "Выбрать звук достижения",
|
"select_achievement_sound": "Выбрать звук достижения",
|
||||||
|
"change_achievement_sound": "Изменить звук достижения",
|
||||||
|
"remove_achievement_sound": "Удалить звук достижения",
|
||||||
|
"preview_sound": "Предпросмотр звука",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"preview": "Предпросмотр",
|
"preview": "Предпросмотр",
|
||||||
"remove": "Удалить",
|
"remove": "Удалить",
|
||||||
@@ -566,7 +598,10 @@
|
|||||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||||
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
|
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
|
||||||
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры"
|
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры",
|
||||||
|
"downloads": "Загрузки",
|
||||||
|
"use_native_http_downloader": "Использовать встроенный HTTP-загрузчик (экспериментально)",
|
||||||
|
"cannot_change_downloader_while_downloading": "Нельзя изменить эту настройку во время загрузки"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Загрузка завершена",
|
"download_complete": "Загрузка завершена",
|
||||||
@@ -643,6 +678,7 @@
|
|||||||
"sending": "Отправка",
|
"sending": "Отправка",
|
||||||
"friend_request_sent": "Запрос в друзья отправлен",
|
"friend_request_sent": "Запрос в друзья отправлен",
|
||||||
"friends": "Друзья",
|
"friends": "Друзья",
|
||||||
|
"badges": "Значки",
|
||||||
"friends_list": "Список друзей",
|
"friends_list": "Список друзей",
|
||||||
"user_not_found": "Пользователь не найден",
|
"user_not_found": "Пользователь не найден",
|
||||||
"block_user": "Заблокировать пользователя",
|
"block_user": "Заблокировать пользователя",
|
||||||
@@ -653,12 +689,17 @@
|
|||||||
"ignore_request": "Игнорировать запрос",
|
"ignore_request": "Игнорировать запрос",
|
||||||
"cancel_request": "Отменить запрос",
|
"cancel_request": "Отменить запрос",
|
||||||
"undo_friendship": "Удалить друга",
|
"undo_friendship": "Удалить друга",
|
||||||
|
"friendship_removed": "Друг удален",
|
||||||
"request_accepted": "Запрос принят",
|
"request_accepted": "Запрос принят",
|
||||||
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
"user_blocked_successfully": "Пользователь успешно заблокирован",
|
||||||
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
"user_block_modal_text": "{{displayName}} будет заблокирован",
|
||||||
"blocked_users": "Заблокированные пользователи",
|
"blocked_users": "Заблокированные пользователи",
|
||||||
"unblock": "Разблокировать",
|
"unblock": "Разблокировать",
|
||||||
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
"no_friends_added": "Вы ещё не добавили ни одного друга",
|
||||||
|
"no_friends_yet": "Вы ещё не добавили ни одного друга",
|
||||||
|
"view_all": "Показать все",
|
||||||
|
"load_more": "Загрузить еще",
|
||||||
|
"loading": "Загрузка",
|
||||||
"pending": "Ожидание",
|
"pending": "Ожидание",
|
||||||
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
||||||
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
||||||
@@ -682,8 +723,16 @@
|
|||||||
"report_reason_other": "Другое",
|
"report_reason_other": "Другое",
|
||||||
"profile_reported": "Жалоба на профиль отправлена",
|
"profile_reported": "Жалоба на профиль отправлена",
|
||||||
"your_friend_code": "Код вашего друга:",
|
"your_friend_code": "Код вашего друга:",
|
||||||
|
"copy_friend_code": "Копировать код друга",
|
||||||
|
"copied": "Скопировано!",
|
||||||
"upload_banner": "Загрузить баннер",
|
"upload_banner": "Загрузить баннер",
|
||||||
"uploading_banner": "Загрузка баннера...",
|
"uploading_banner": "Загрузка баннера...",
|
||||||
|
"change_banner": "Изменить баннер",
|
||||||
|
"replace_banner": "Заменить баннер",
|
||||||
|
"remove_banner": "Удалить баннер",
|
||||||
|
"remove_banner_modal_title": "Удалить баннер?",
|
||||||
|
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
|
||||||
|
"remove": "Удалить",
|
||||||
"background_image_updated": "Фоновое изображение обновлено",
|
"background_image_updated": "Фоновое изображение обновлено",
|
||||||
"stats": "Статистика",
|
"stats": "Статистика",
|
||||||
"achievements": "Достижения",
|
"achievements": "Достижения",
|
||||||
@@ -701,9 +750,9 @@
|
|||||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||||
"karma": "Карма",
|
"karma": "Карма",
|
||||||
"karma_count": "карма",
|
"karma_count": "карма",
|
||||||
"karma_description": "Заработана положительными оценками отзывов",
|
|
||||||
"user_reviews": "Отзывы",
|
"user_reviews": "Отзывы",
|
||||||
"loading_reviews": "Загрузка отзывов...",
|
"loading_reviews": "Загрузка отзывов...",
|
||||||
|
"wrapped_2025": "Wrapped 2025",
|
||||||
"no_reviews": "Пока нет отзывов",
|
"no_reviews": "Пока нет отзывов",
|
||||||
"delete_review": "Удалить отзыв"
|
"delete_review": "Удалить отзыв"
|
||||||
},
|
},
|
||||||
@@ -756,5 +805,41 @@
|
|||||||
"all_games": "Все игры",
|
"all_games": "Все игры",
|
||||||
"recently_played": "Недавно сыгранные",
|
"recently_played": "Недавно сыгранные",
|
||||||
"favorites": "Избранное"
|
"favorites": "Избранное"
|
||||||
|
},
|
||||||
|
"notifications_page": {
|
||||||
|
"title": "Уведомления",
|
||||||
|
"mark_all_as_read": "Отметить все как прочитанные",
|
||||||
|
"clear_all": "Очистить все",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"empty_title": "Нет уведомлений",
|
||||||
|
"empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
|
||||||
|
"empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
|
||||||
|
"filter_all": "Все",
|
||||||
|
"filter_unread": "Непрочитанные",
|
||||||
|
"filter_friends": "Друзья",
|
||||||
|
"filter_badges": "Значки",
|
||||||
|
"filter_upvotes": "Голоса",
|
||||||
|
"filter_local": "Локальные",
|
||||||
|
"load_more": "Загрузить еще",
|
||||||
|
"dismiss": "Отклонить",
|
||||||
|
"accept": "Принять",
|
||||||
|
"refuse": "Отклонить",
|
||||||
|
"notification": "Уведомление",
|
||||||
|
"friend_request_received_title": "Новый запрос в друзья!",
|
||||||
|
"friend_request_received_description": "{{displayName}} хочет добавить вас в друзья",
|
||||||
|
"friend_request_accepted_title": "Запрос в друзья принят!",
|
||||||
|
"friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья",
|
||||||
|
"badge_received_title": "Вы получили новый значок!",
|
||||||
|
"badge_received_description": "{{badgeName}}",
|
||||||
|
"review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!",
|
||||||
|
"review_upvote_description": "Ваш отзыв получил {{count}} новых голосов",
|
||||||
|
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
|
||||||
|
"failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные",
|
||||||
|
"cleared_all": "Все уведомления очищены",
|
||||||
|
"failed_to_clear": "Не удалось очистить уведомления",
|
||||||
|
"failed_to_load": "Не удалось загрузить уведомления",
|
||||||
|
"failed_to_dismiss": "Не удалось отклонить уведомление",
|
||||||
|
"friend_request_accepted": "Запрос в друзья принят",
|
||||||
|
"friend_request_refused": "Запрос в друзья отклонен"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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": "Досягнення розблоковано",
|
||||||
|
|||||||
@@ -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": "已置顶",
|
||||||
|
|||||||
23
src/main/events/library/delete-archive.ts
Normal file
23
src/main/events/library/delete-archive.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const deleteArchive = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
filePath: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
await fs.promises.unlink(filePath);
|
||||||
|
logger.info(`Deleted archive: ${filePath}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to delete archive: ${filePath}`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("deleteArchive", deleteArchive);
|
||||||
@@ -22,6 +22,7 @@ const extractGameDownload = async (
|
|||||||
await downloadsSublevel.put(gameKey, {
|
await downloadsSublevel.put(gameKey, {
|
||||||
...download,
|
...download,
|
||||||
extracting: true,
|
extracting: true,
|
||||||
|
extractionProgress: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const gameFilesManager = new GameFilesManager(shop, objectId);
|
const gameFilesManager = new GameFilesManager(shop, objectId);
|
||||||
|
|||||||
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
59
src/main/events/library/get-game-installer-action-type.ts
Normal 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);
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import "./close-game";
|
|||||||
import "./copy-custom-game-asset";
|
import "./copy-custom-game-asset";
|
||||||
import "./create-game-shortcut";
|
import "./create-game-shortcut";
|
||||||
import "./create-steam-shortcut";
|
import "./create-steam-shortcut";
|
||||||
|
import "./delete-archive";
|
||||||
import "./delete-game-folder";
|
import "./delete-game-folder";
|
||||||
import "./extract-game-download";
|
import "./extract-game-download";
|
||||||
import "./get-default-wine-prefix-selection-path";
|
import "./get-default-wine-prefix-selection-path";
|
||||||
import "./get-game-by-object-id";
|
import "./get-game-by-object-id";
|
||||||
|
import "./get-game-installer-action-type";
|
||||||
import "./get-library";
|
import "./get-library";
|
||||||
import "./open-game-executable-path";
|
import "./open-game-executable-path";
|
||||||
import "./open-game-installer-path";
|
import "./open-game-installer-path";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const clearAllLocalNotifications = async () => {
|
||||||
|
await LocalNotificationManager.clearAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);
|
||||||
11
src/main/events/notifications/delete-local-notification.ts
Normal file
11
src/main/events/notifications/delete-local-notification.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const deleteLocalNotification = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
await LocalNotificationManager.deleteNotification(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("deleteLocalNotification", deleteLocalNotification);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const getLocalNotificationsCount = async () => {
|
||||||
|
return LocalNotificationManager.getUnreadCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);
|
||||||
8
src/main/events/notifications/get-local-notifications.ts
Normal file
8
src/main/events/notifications/get-local-notifications.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const getLocalNotifications = async () => {
|
||||||
|
return LocalNotificationManager.getNotifications();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getLocalNotifications", getLocalNotifications);
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import "./publish-new-repacks-notification";
|
import "./publish-new-repacks-notification";
|
||||||
import "./show-achievement-test-notification";
|
import "./show-achievement-test-notification";
|
||||||
import "./update-achievement-notification-window";
|
import "./update-achievement-notification-window";
|
||||||
|
import "./get-local-notifications";
|
||||||
|
import "./get-local-notifications-count";
|
||||||
|
import "./mark-local-notification-read";
|
||||||
|
import "./mark-all-local-notifications-read";
|
||||||
|
import "./delete-local-notification";
|
||||||
|
import "./clear-all-local-notifications";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const markAllLocalNotificationsRead = async () => {
|
||||||
|
await LocalNotificationManager.markAllAsRead();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const markLocalNotificationRead = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
await LocalNotificationManager.markAsRead(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("markLocalNotificationRead", markLocalNotificationRead);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import "./get-me";
|
import "./get-me";
|
||||||
import "./process-profile-image";
|
import "./process-profile-image";
|
||||||
import "./sync-friend-requests";
|
|
||||||
import "./update-profile";
|
import "./update-profile";
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { registerEvent } from "../register-event";
|
|
||||||
import { HydraApi, WindowManager } from "@main/services";
|
|
||||||
import { UserNotLoggedInError } from "@shared";
|
|
||||||
import type { FriendRequestSync } from "@types";
|
|
||||||
|
|
||||||
export const syncFriendRequests = async () => {
|
|
||||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
|
|
||||||
.then((res) => {
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
|
||||||
"on-sync-friend-requests",
|
|
||||||
res
|
|
||||||
);
|
|
||||||
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err instanceof UserNotLoggedInError) {
|
|
||||||
return { friendRequestCount: 0 } as FriendRequestSync;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
registerEvent("syncFriendRequests", syncFriendRequests);
|
|
||||||
@@ -51,22 +51,30 @@ const updateProfile = async (
|
|||||||
"backgroundImageUrl",
|
"backgroundImageUrl",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (updateProfile.profileImageUrl) {
|
if (updateProfile.profileImageUrl !== undefined) {
|
||||||
const profileImageUrl = await uploadImage(
|
if (updateProfile.profileImageUrl === null) {
|
||||||
"profile-image",
|
payload["profileImageUrl"] = null;
|
||||||
updateProfile.profileImageUrl
|
} else {
|
||||||
).catch(() => undefined);
|
const profileImageUrl = await uploadImage(
|
||||||
|
"profile-image",
|
||||||
|
updateProfile.profileImageUrl
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
payload["profileImageUrl"] = profileImageUrl;
|
payload["profileImageUrl"] = profileImageUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateProfile.backgroundImageUrl) {
|
if (updateProfile.backgroundImageUrl !== undefined) {
|
||||||
const backgroundImageUrl = await uploadImage(
|
if (updateProfile.backgroundImageUrl === null) {
|
||||||
"background-image",
|
payload["backgroundImageUrl"] = null;
|
||||||
updateProfile.backgroundImageUrl
|
} else {
|
||||||
).catch(() => undefined);
|
const backgroundImageUrl = await uploadImage(
|
||||||
|
"background-image",
|
||||||
|
updateProfile.backgroundImageUrl
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
payload["backgroundImageUrl"] = backgroundImageUrl;
|
payload["backgroundImageUrl"] = backgroundImageUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return patchUserProfile(payload);
|
return patchUserProfile(payload);
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ const resumeGameDownload = async (
|
|||||||
|
|
||||||
const download = await downloadsSublevel.get(gameKey);
|
const download = await downloadsSublevel.get(gameKey);
|
||||||
|
|
||||||
if (download?.status === "paused") {
|
if (
|
||||||
|
download &&
|
||||||
|
(download.status === "paused" || download.status === "active") &&
|
||||||
|
download.progress !== 1
|
||||||
|
) {
|
||||||
await DownloadManager.pauseDownload();
|
await DownloadManager.pauseDownload();
|
||||||
|
|
||||||
for await (const [key, value] of downloadsSublevel.iterator()) {
|
for await (const [key, value] of downloadsSublevel.iterator()) {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const startGameDownload = async (
|
|||||||
const game = await gamesSublevel.get(gameKey);
|
const game = await gamesSublevel.get(gameKey);
|
||||||
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||||
|
|
||||||
/* Delete any previous download */
|
|
||||||
await downloadsSublevel.del(gameKey);
|
await downloadsSublevel.del(gameKey);
|
||||||
|
|
||||||
if (game) {
|
if (game) {
|
||||||
@@ -82,6 +81,7 @@ const startGameDownload = async (
|
|||||||
queued: true,
|
queued: true,
|
||||||
extracting: false,
|
extracting: false,
|
||||||
automaticallyExtract,
|
automaticallyExtract,
|
||||||
|
extractionProgress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +123,42 @@ const startGameDownload = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
if (downloader === Downloader.Buzzheavier) {
|
||||||
|
if (err.message.includes("Rate limit")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Buzzheavier: Rate limit exceeded",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err.message.includes("not found") ||
|
||||||
|
err.message.includes("deleted")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Buzzheavier: File not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloader === Downloader.FuckingFast) {
|
||||||
|
if (err.message.includes("Rate limit")) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "FuckingFast: Rate limit exceeded",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err.message.includes("not found") ||
|
||||||
|
err.message.includes("deleted")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "FuckingFast: File not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: false, error: err.message };
|
return { ok: false, error: err.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @generated by protobuf-ts 2.10.0
|
// @generated by protobuf-ts 2.11.1
|
||||||
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||||
// tslint:disable
|
// tslint:disable
|
||||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||||
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
|
|||||||
*/
|
*/
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
* @generated from protobuf field: int32 friend_request_count = 1
|
||||||
*/
|
*/
|
||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: optional string sender_id = 2;
|
* @generated from protobuf field: optional string sender_id = 2
|
||||||
*/
|
*/
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
}
|
}
|
||||||
@@ -28,18 +28,27 @@ export interface FriendRequest {
|
|||||||
*/
|
*/
|
||||||
export interface FriendGameSession {
|
export interface FriendGameSession {
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string object_id = 1;
|
* @generated from protobuf field: string object_id = 1
|
||||||
*/
|
*/
|
||||||
objectId: string;
|
objectId: string;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string shop = 2;
|
* @generated from protobuf field: string shop = 2
|
||||||
*/
|
*/
|
||||||
shop: string;
|
shop: string;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string friend_id = 3;
|
* @generated from protobuf field: string friend_id = 3
|
||||||
*/
|
*/
|
||||||
friendId: string;
|
friendId: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @generated from protobuf message Notification
|
||||||
|
*/
|
||||||
|
export interface Notification {
|
||||||
|
/**
|
||||||
|
* @generated from protobuf field: int32 notification_count = 1
|
||||||
|
*/
|
||||||
|
notificationCount: number;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf message Envelope
|
* @generated from protobuf message Envelope
|
||||||
*/
|
*/
|
||||||
@@ -51,17 +60,24 @@ export interface Envelope {
|
|||||||
| {
|
| {
|
||||||
oneofKind: "friendRequest";
|
oneofKind: "friendRequest";
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
* @generated from protobuf field: FriendRequest friend_request = 1
|
||||||
*/
|
*/
|
||||||
friendRequest: FriendRequest;
|
friendRequest: FriendRequest;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
oneofKind: "friendGameSession";
|
oneofKind: "friendGameSession";
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
* @generated from protobuf field: FriendGameSession friend_game_session = 2
|
||||||
*/
|
*/
|
||||||
friendGameSession: FriendGameSession;
|
friendGameSession: FriendGameSession;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
oneofKind: "notification";
|
||||||
|
/**
|
||||||
|
* @generated from protobuf field: Notification notification = 3
|
||||||
|
*/
|
||||||
|
notification: Notification;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
oneofKind: undefined;
|
oneofKind: undefined;
|
||||||
};
|
};
|
||||||
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
|||||||
*/
|
*/
|
||||||
export const FriendGameSession = new FriendGameSession$Type();
|
export const FriendGameSession = new FriendGameSession$Type();
|
||||||
// @generated message type with reflection information, may provide speed optimized methods
|
// @generated message type with reflection information, may provide speed optimized methods
|
||||||
|
class Notification$Type extends MessageType<Notification> {
|
||||||
|
constructor() {
|
||||||
|
super("Notification", [
|
||||||
|
{
|
||||||
|
no: 1,
|
||||||
|
name: "notification_count",
|
||||||
|
kind: "scalar",
|
||||||
|
T: 5 /*ScalarType.INT32*/,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
create(value?: PartialMessage<Notification>): Notification {
|
||||||
|
const message = globalThis.Object.create(this.messagePrototype!);
|
||||||
|
message.notificationCount = 0;
|
||||||
|
if (value !== undefined)
|
||||||
|
reflectionMergePartial<Notification>(this, message, value);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
internalBinaryRead(
|
||||||
|
reader: IBinaryReader,
|
||||||
|
length: number,
|
||||||
|
options: BinaryReadOptions,
|
||||||
|
target?: Notification
|
||||||
|
): Notification {
|
||||||
|
let message = target ?? this.create(),
|
||||||
|
end = reader.pos + length;
|
||||||
|
while (reader.pos < end) {
|
||||||
|
let [fieldNo, wireType] = reader.tag();
|
||||||
|
switch (fieldNo) {
|
||||||
|
case /* int32 notification_count */ 1:
|
||||||
|
message.notificationCount = reader.int32();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
let u = options.readUnknownField;
|
||||||
|
if (u === "throw")
|
||||||
|
throw new globalThis.Error(
|
||||||
|
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||||
|
);
|
||||||
|
let d = reader.skip(wireType);
|
||||||
|
if (u !== false)
|
||||||
|
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||||
|
this.typeName,
|
||||||
|
message,
|
||||||
|
fieldNo,
|
||||||
|
wireType,
|
||||||
|
d
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
internalBinaryWrite(
|
||||||
|
message: Notification,
|
||||||
|
writer: IBinaryWriter,
|
||||||
|
options: BinaryWriteOptions
|
||||||
|
): IBinaryWriter {
|
||||||
|
/* int32 notification_count = 1; */
|
||||||
|
if (message.notificationCount !== 0)
|
||||||
|
writer.tag(1, WireType.Varint).int32(message.notificationCount);
|
||||||
|
let u = options.writeUnknownFields;
|
||||||
|
if (u !== false)
|
||||||
|
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||||
|
this.typeName,
|
||||||
|
message,
|
||||||
|
writer
|
||||||
|
);
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @generated MessageType for protobuf message Notification
|
||||||
|
*/
|
||||||
|
export const Notification = new Notification$Type();
|
||||||
|
// @generated message type with reflection information, may provide speed optimized methods
|
||||||
class Envelope$Type extends MessageType<Envelope> {
|
class Envelope$Type extends MessageType<Envelope> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Envelope", [
|
super("Envelope", [
|
||||||
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
oneof: "payload",
|
oneof: "payload",
|
||||||
T: () => FriendGameSession,
|
T: () => FriendGameSession,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
no: 3,
|
||||||
|
name: "notification",
|
||||||
|
kind: "message",
|
||||||
|
oneof: "payload",
|
||||||
|
T: () => Notification,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
create(value?: PartialMessage<Envelope>): Envelope {
|
create(value?: PartialMessage<Envelope>): Envelope {
|
||||||
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case /* Notification notification */ 3:
|
||||||
|
message.payload = {
|
||||||
|
oneofKind: "notification",
|
||||||
|
notification: Notification.internalBinaryRead(
|
||||||
|
reader,
|
||||||
|
reader.uint32(),
|
||||||
|
options,
|
||||||
|
(message.payload as any).notification
|
||||||
|
),
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
let u = options.readUnknownField;
|
let u = options.readUnknownField;
|
||||||
if (u === "throw")
|
if (u === "throw")
|
||||||
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||||
options
|
options
|
||||||
).join();
|
).join();
|
||||||
|
/* Notification notification = 3; */
|
||||||
|
if (message.payload.oneofKind === "notification")
|
||||||
|
Notification.internalBinaryWrite(
|
||||||
|
message.payload.notification,
|
||||||
|
writer.tag(3, WireType.LengthDelimited).fork(),
|
||||||
|
options
|
||||||
|
).join();
|
||||||
let u = options.writeUnknownFields;
|
let u = options.writeUnknownFields;
|
||||||
if (u !== false)
|
if (u !== false)
|
||||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
|||||||
export * from "./themes";
|
export * from "./themes";
|
||||||
export * from "./download-sources";
|
export * from "./download-sources";
|
||||||
export * from "./downloadSourcesCheckTimestamp";
|
export * from "./downloadSourcesCheckTimestamp";
|
||||||
|
export * from "./local-notifications";
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export const levelKeys = {
|
|||||||
downloadSources: "downloadSources",
|
downloadSources: "downloadSources",
|
||||||
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||||
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
||||||
|
localNotifications: "localNotifications",
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/main/level/sublevels/local-notifications.ts
Normal file
11
src/main/level/sublevels/local-notifications.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LocalNotification } from "@types";
|
||||||
|
|
||||||
|
import { db } from "../level";
|
||||||
|
import { levelKeys } from "./keys";
|
||||||
|
|
||||||
|
export const localNotificationsSublevel = db.sublevel<
|
||||||
|
string,
|
||||||
|
LocalNotification
|
||||||
|
>(levelKeys.localNotifications, {
|
||||||
|
valueEncoding: "json",
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||||
import { sortBy } 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";
|
||||||
|
|
||||||
@@ -33,9 +34,7 @@ export const loadState = async () => {
|
|||||||
|
|
||||||
await import("./events");
|
await import("./events");
|
||||||
|
|
||||||
if (process.platform !== "darwin") {
|
Aria2.spawn();
|
||||||
Aria2.spawn();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userPreferences?.realDebridApiToken) {
|
if (userPreferences?.realDebridApiToken) {
|
||||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||||
@@ -59,8 +58,10 @@ export const loadState = async () => {
|
|||||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||||
void syncDownloadSourcesFromApi();
|
void syncDownloadSourcesFromApi();
|
||||||
|
|
||||||
// Check for new download options on startup
|
// Check for new download options on startup (if enabled)
|
||||||
DownloadSourcesChecker.checkForChanges();
|
(async () => {
|
||||||
|
await DownloadSourcesChecker.checkForChanges();
|
||||||
|
})();
|
||||||
WSClient.connect();
|
WSClient.connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,21 +69,50 @@ export const loadState = async () => {
|
|||||||
.values()
|
.values()
|
||||||
.all()
|
.all()
|
||||||
.then((games) => {
|
.then((games) => {
|
||||||
return sortBy(games, "timestamp", "DESC");
|
return orderBy(games, "timestamp", "desc");
|
||||||
});
|
});
|
||||||
|
|
||||||
downloads.forEach((download) => {
|
let interruptedDownload: Download | null = null;
|
||||||
|
|
||||||
|
for (const download of downloads) {
|
||||||
|
const downloadKey = levelKeys.game(download.shop, download.objectId);
|
||||||
|
|
||||||
|
// Reset extracting state
|
||||||
if (download.extracting) {
|
if (download.extracting) {
|
||||||
downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), {
|
await downloadsSublevel.put(downloadKey, {
|
||||||
...download,
|
...download,
|
||||||
extracting: false,
|
extracting: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const [nextItemOnQueue] = downloads.filter((game) => game.queued);
|
// Find interrupted active download (download that was running when app closed)
|
||||||
|
// Mark it as paused but remember it for auto-resume
|
||||||
|
if (download.status === "active" && !interruptedDownload) {
|
||||||
|
interruptedDownload = download;
|
||||||
|
await downloadsSublevel.put(downloadKey, {
|
||||||
|
...download,
|
||||||
|
status: "paused",
|
||||||
|
});
|
||||||
|
} else if (download.status === "active") {
|
||||||
|
// Mark other active downloads as paused
|
||||||
|
await downloadsSublevel.put(downloadKey, {
|
||||||
|
...download,
|
||||||
|
status: "paused",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const downloadsToSeed = downloads.filter(
|
// Re-fetch downloads after status updates
|
||||||
|
const updatedDownloads = await downloadsSublevel
|
||||||
|
.values()
|
||||||
|
.all()
|
||||||
|
.then((games) => orderBy(games, "timestamp", "desc"));
|
||||||
|
|
||||||
|
// Prioritize interrupted download, then queued downloads
|
||||||
|
const downloadToResume =
|
||||||
|
interruptedDownload ?? updatedDownloads.find((game) => game.queued);
|
||||||
|
|
||||||
|
const downloadsToSeed = updatedDownloads.filter(
|
||||||
(game) =>
|
(game) =>
|
||||||
game.shouldSeed &&
|
game.shouldSeed &&
|
||||||
game.downloader === Downloader.Torrent &&
|
game.downloader === Downloader.Torrent &&
|
||||||
@@ -90,7 +120,23 @@ export const loadState = async () => {
|
|||||||
game.uri !== null
|
game.uri !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
|
// For torrents or if JS downloader is disabled, use Python RPC
|
||||||
|
const isTorrent = downloadToResume?.downloader === Downloader.Torrent;
|
||||||
|
// Default to true - native HTTP downloader is enabled by default
|
||||||
|
const useJsDownloader =
|
||||||
|
(userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent;
|
||||||
|
|
||||||
|
if (useJsDownloader && downloadToResume) {
|
||||||
|
// Start Python RPC for seeding only, then resume HTTP download with JS
|
||||||
|
await DownloadManager.startRPC(undefined, downloadsToSeed);
|
||||||
|
await DownloadManager.startDownload(downloadToResume).catch((err) => {
|
||||||
|
// If resume fails, just log it - user can manually retry
|
||||||
|
logger.error("Failed to auto-resume download:", err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Use Python RPC for everything (torrent or fallback)
|
||||||
|
await DownloadManager.startRPC(downloadToResume, downloadsToSeed);
|
||||||
|
}
|
||||||
|
|
||||||
startMainLoop();
|
startMainLoop();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import cp from "node:child_process";
|
import Seven, { CommandLineSwitches } from "node-7z";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
@@ -9,6 +9,17 @@ export const binaryName = {
|
|||||||
win32: "7z.exe",
|
win32: "7z.exe",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ExtractionProgress {
|
||||||
|
percent: number;
|
||||||
|
fileCount: number;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionResult {
|
||||||
|
success: boolean;
|
||||||
|
extractedFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class SevenZip {
|
export class SevenZip {
|
||||||
private static readonly binaryPath = app.isPackaged
|
private static readonly binaryPath = app.isPackaged
|
||||||
? path.join(process.resourcesPath, binaryName[process.platform])
|
? path.join(process.resourcesPath, binaryName[process.platform])
|
||||||
@@ -32,43 +43,109 @@ export class SevenZip {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
passwords?: string[];
|
passwords?: string[];
|
||||||
},
|
},
|
||||||
successCb: () => void,
|
onProgress?: (progress: ExtractionProgress) => void
|
||||||
errorCb: () => void
|
): Promise<ExtractionResult> {
|
||||||
) {
|
return new Promise((resolve, reject) => {
|
||||||
const tryPassword = (index = -1) => {
|
const tryPassword = (index = 0) => {
|
||||||
const password = passwords[index] ?? "";
|
const password = passwords[index] ?? "";
|
||||||
logger.info(`Trying password ${password} on ${filePath}`);
|
logger.info(
|
||||||
|
`Trying password "${password || "(empty)"}" on ${filePath}`
|
||||||
|
);
|
||||||
|
|
||||||
const args = ["x", filePath, "-y", "-p" + password];
|
const extractedFiles: string[] = [];
|
||||||
|
let fileCount = 0;
|
||||||
|
|
||||||
if (outputPath) {
|
const options: CommandLineSwitches = {
|
||||||
args.push("-o" + outputPath);
|
$bin: this.binaryPath,
|
||||||
}
|
$progress: true,
|
||||||
|
yes: true,
|
||||||
|
password: password || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const child = cp.execFile(this.binaryPath, args, {
|
if (outputPath) {
|
||||||
cwd,
|
options.outputDir = outputPath;
|
||||||
});
|
|
||||||
|
|
||||||
child.once("exit", (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
successCb();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index < passwords.length - 1) {
|
const stream = Seven.extractFull(filePath, outputPath || cwd || ".", {
|
||||||
|
...options,
|
||||||
|
$spawnOptions: cwd ? { cwd } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("progress", (progress) => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
percent: progress.percent,
|
||||||
|
fileCount: fileCount,
|
||||||
|
file: progress.fileCount?.toString() || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("data", (data) => {
|
||||||
|
if (data.file) {
|
||||||
|
extractedFiles.push(data.file);
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("end", () => {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
|
`Successfully extracted ${filePath} (${extractedFiles.length} files)`
|
||||||
);
|
);
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
extractedFiles,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tryPassword(index + 1);
|
stream.on("error", (err) => {
|
||||||
} else {
|
logger.error(`Extraction error for ${filePath}:`, err);
|
||||||
logger.info(`Failed to extract file: ${filePath}`);
|
|
||||||
|
|
||||||
errorCb();
|
if (index < passwords.length - 1) {
|
||||||
|
logger.info(
|
||||||
|
`Failed to extract file: ${filePath} with password: "${password}". Trying next password...`
|
||||||
|
);
|
||||||
|
tryPassword(index + 1);
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`Failed to extract file: ${filePath} after trying all passwords`
|
||||||
|
);
|
||||||
|
reject(new Error(`Failed to extract file: ${filePath}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tryPassword(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static listFiles(
|
||||||
|
filePath: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
const options: CommandLineSwitches = {
|
||||||
|
$bin: this.binaryPath,
|
||||||
|
password: password || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = Seven.list(filePath, options);
|
||||||
|
|
||||||
|
stream.on("data", (data) => {
|
||||||
|
if (data.file) {
|
||||||
|
files.push(data.file);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
tryPassword();
|
stream.on("end", () => {
|
||||||
|
resolve(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ export class Aria2 {
|
|||||||
private static process: cp.ChildProcess | null = null;
|
private static process: cp.ChildProcess | null = null;
|
||||||
|
|
||||||
public static spawn() {
|
public static spawn() {
|
||||||
const binaryPath = app.isPackaged
|
const binaryPath =
|
||||||
? path.join(process.resourcesPath, "aria2c")
|
process.platform === "darwin"
|
||||||
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
? "aria2c"
|
||||||
|
: app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "aria2c")
|
||||||
|
: path.join(__dirname, "..", "..", "binaries", "aria2c");
|
||||||
|
|
||||||
this.process = cp.spawn(
|
this.process = cp.spawn(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
|
|||||||
@@ -74,21 +74,16 @@ export class DeckyPlugin {
|
|||||||
|
|
||||||
await fs.promises.mkdir(extractPath, { recursive: true });
|
await fs.promises.mkdir(extractPath, { recursive: true });
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
SevenZip.extractFile(
|
await SevenZip.extractFile({
|
||||||
{
|
filePath: zipPath,
|
||||||
filePath: zipPath,
|
outputPath: extractPath,
|
||||||
outputPath: extractPath,
|
});
|
||||||
},
|
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||||
() => {
|
return extractPath;
|
||||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
} catch {
|
||||||
resolve(extractPath);
|
throw new Error("Failed to extract plugin");
|
||||||
},
|
}
|
||||||
() => {
|
|
||||||
reject(new Error("Failed to extract plugin"));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static needsSudo(): boolean {
|
private static needsSudo(): boolean {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
updateDownloadSourcesCheckBaseline,
|
updateDownloadSourcesCheckBaseline,
|
||||||
updateDownloadSourcesSinceValue,
|
updateDownloadSourcesSinceValue,
|
||||||
downloadSourcesSublevel,
|
downloadSourcesSublevel,
|
||||||
|
db,
|
||||||
|
levelKeys,
|
||||||
} from "@main/level";
|
} from "@main/level";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import type { Game } from "@types";
|
import type { Game, UserPreferences } from "@types";
|
||||||
|
|
||||||
interface DownloadSourcesChangeResponse {
|
interface DownloadSourcesChangeResponse {
|
||||||
shop: string;
|
shop: string;
|
||||||
@@ -101,6 +103,20 @@ export class DownloadSourcesChecker {
|
|||||||
logger.info("DownloadSourcesChecker.checkForChanges() called");
|
logger.info("DownloadSourcesChecker.checkForChanges() called");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
|
levelKeys.userPreferences,
|
||||||
|
{
|
||||||
|
valueEncoding: "json",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userPreferences?.enableNewDownloadOptionsBadges === false) {
|
||||||
|
logger.info(
|
||||||
|
"New download options badges are disabled, skipping download sources check"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all installed games (excluding custom games)
|
// Get all installed games (excluding custom games)
|
||||||
const installedGames = await gamesSublevel.values().all();
|
const installedGames = await gamesSublevel.values().all();
|
||||||
const nonCustomGames = installedGames.filter(
|
const nonCustomGames = installedGames.filter(
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
|
|||||||
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
||||||
import {
|
import {
|
||||||
GofileApi,
|
GofileApi,
|
||||||
QiwiApi,
|
|
||||||
DatanodesApi,
|
DatanodesApi,
|
||||||
MediafireApi,
|
MediafireApi,
|
||||||
PixelDrainApi,
|
PixelDrainApi,
|
||||||
|
VikingFileApi,
|
||||||
|
RootzApi,
|
||||||
} from "../hosters";
|
} from "../hosters";
|
||||||
import { PythonRPC } from "../python-rpc";
|
import { PythonRPC } from "../python-rpc";
|
||||||
import {
|
import {
|
||||||
@@ -17,16 +18,131 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import { calculateETA, getDirSize } from "./helpers";
|
import { calculateETA, getDirSize } from "./helpers";
|
||||||
import { RealDebridClient } from "./real-debrid";
|
import { RealDebridClient } from "./real-debrid";
|
||||||
import path from "path";
|
import path from "node:path";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||||
import { sortBy } from "lodash-es";
|
import { sortBy } from "lodash-es";
|
||||||
import { TorBoxClient } from "./torbox";
|
import { TorBoxClient } from "./torbox";
|
||||||
import { GameFilesManager } from "../game-files-manager";
|
import { GameFilesManager } from "../game-files-manager";
|
||||||
import { HydraDebridClient } from "./hydra-debrid";
|
import { HydraDebridClient } from "./hydra-debrid";
|
||||||
|
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
||||||
|
import { JsHttpDownloader } from "./js-http-downloader";
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static downloadingGameId: string | null = null;
|
private static downloadingGameId: string | null = null;
|
||||||
|
private static jsDownloader: JsHttpDownloader | null = null;
|
||||||
|
private static usingJsDownloader = false;
|
||||||
|
private static isPreparingDownload = false;
|
||||||
|
|
||||||
|
private static extractFilename(
|
||||||
|
url: string,
|
||||||
|
originalUrl?: string
|
||||||
|
): string | undefined {
|
||||||
|
if (originalUrl?.includes("#")) {
|
||||||
|
const hashPart = originalUrl.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||||
|
return hashPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("#")) {
|
||||||
|
const hashPart = url.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||||
|
return hashPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
const pathParts = pathname.split("/");
|
||||||
|
const filename = pathParts.at(-1);
|
||||||
|
|
||||||
|
if (filename?.includes(".") && filename.length > 0) {
|
||||||
|
return decodeURIComponent(filename);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sanitizeFilename(filename: string): string {
|
||||||
|
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static resolveFilename(
|
||||||
|
resumingFilename: string | undefined,
|
||||||
|
originalUrl: string,
|
||||||
|
downloadUrl: string
|
||||||
|
): string | undefined {
|
||||||
|
if (resumingFilename) return resumingFilename;
|
||||||
|
|
||||||
|
const extracted =
|
||||||
|
this.extractFilename(originalUrl, downloadUrl) ||
|
||||||
|
this.extractFilename(downloadUrl);
|
||||||
|
|
||||||
|
return extracted ? this.sanitizeFilename(extracted) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buildDownloadOptions(
|
||||||
|
url: string,
|
||||||
|
savePath: string,
|
||||||
|
filename: string | undefined,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
savePath,
|
||||||
|
filename,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createDownloadPayload(
|
||||||
|
directUrl: string,
|
||||||
|
originalUrl: string,
|
||||||
|
downloadId: string,
|
||||||
|
savePath: string
|
||||||
|
) {
|
||||||
|
const filename =
|
||||||
|
this.extractFilename(originalUrl, directUrl) ||
|
||||||
|
this.extractFilename(directUrl);
|
||||||
|
const sanitizedFilename = filename
|
||||||
|
? this.sanitizeFilename(filename)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (sanitizedFilename) {
|
||||||
|
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||||
|
} else {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] No filename extracted, aria2 will use default`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "start" as const,
|
||||||
|
game_id: downloadId,
|
||||||
|
url: directUrl,
|
||||||
|
save_path: savePath,
|
||||||
|
out: sanitizedFilename,
|
||||||
|
allow_multiple_connections: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async shouldUseJsDownloader(): Promise<boolean> {
|
||||||
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
|
levelKeys.userPreferences,
|
||||||
|
{ valueEncoding: "json" }
|
||||||
|
);
|
||||||
|
// Default to true - native HTTP downloader is enabled by default (opt-out)
|
||||||
|
return userPreferences?.useNativeHttpDownloader ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isHttpDownloader(downloader: Downloader): boolean {
|
||||||
|
return downloader !== Downloader.Torrent;
|
||||||
|
}
|
||||||
|
|
||||||
public static async startRPC(
|
public static async startRPC(
|
||||||
download?: Download,
|
download?: Download,
|
||||||
@@ -52,7 +168,87 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getDownloadStatus() {
|
private static async getDownloadStatusFromJs(): Promise<DownloadProgress | null> {
|
||||||
|
if (!this.downloadingGameId) return null;
|
||||||
|
|
||||||
|
const downloadId = this.downloadingGameId;
|
||||||
|
|
||||||
|
// Return a "preparing" status while fetching download options
|
||||||
|
if (this.isPreparingDownload) {
|
||||||
|
try {
|
||||||
|
const download = await downloadsSublevel.get(downloadId);
|
||||||
|
if (!download) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPeers: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
downloadSpeed: 0,
|
||||||
|
timeRemaining: -1,
|
||||||
|
isDownloadingMetadata: true, // Use this to indicate "preparing"
|
||||||
|
isCheckingFiles: false,
|
||||||
|
progress: 0,
|
||||||
|
gameId: downloadId,
|
||||||
|
download,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.jsDownloader) return null;
|
||||||
|
|
||||||
|
const status = this.jsDownloader.getDownloadStatus();
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const download = await downloadsSublevel.get(downloadId);
|
||||||
|
if (!download) return null;
|
||||||
|
|
||||||
|
const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } =
|
||||||
|
status;
|
||||||
|
|
||||||
|
// Only update fileSize in database if we actually know it (> 0)
|
||||||
|
// Otherwise keep the existing value to avoid showing "0 B"
|
||||||
|
const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize;
|
||||||
|
|
||||||
|
const updatedDownload = {
|
||||||
|
...download,
|
||||||
|
bytesDownloaded,
|
||||||
|
fileSize: effectiveFileSize,
|
||||||
|
progress,
|
||||||
|
folderName,
|
||||||
|
status:
|
||||||
|
status.status === "complete"
|
||||||
|
? ("complete" as const)
|
||||||
|
: ("active" as const),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.status === "active" || status.status === "complete") {
|
||||||
|
await downloadsSublevel.put(downloadId, updatedDownload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numPeers: 0,
|
||||||
|
numSeeds: 0,
|
||||||
|
downloadSpeed,
|
||||||
|
timeRemaining: calculateETA(
|
||||||
|
effectiveFileSize ?? 0,
|
||||||
|
bytesDownloaded,
|
||||||
|
downloadSpeed
|
||||||
|
),
|
||||||
|
isDownloadingMetadata: false,
|
||||||
|
isCheckingFiles: false,
|
||||||
|
progress,
|
||||||
|
gameId: downloadId,
|
||||||
|
download: updatedDownload,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[DownloadManager] Error getting JS download status:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
|
||||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||||
"/status"
|
"/status"
|
||||||
);
|
);
|
||||||
@@ -101,114 +297,141 @@ export class DownloadManager {
|
|||||||
gameId: downloadId,
|
gameId: downloadId,
|
||||||
download,
|
download,
|
||||||
} as DownloadProgress;
|
} as DownloadProgress;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
|
||||||
|
if (this.usingJsDownloader) {
|
||||||
|
return this.getDownloadStatusFromJs();
|
||||||
|
}
|
||||||
|
return this.getDownloadStatusFromRpc();
|
||||||
|
}
|
||||||
|
|
||||||
public static async watchDownloads() {
|
public static async watchDownloads() {
|
||||||
const status = await this.getDownloadStatus();
|
const status = await this.getDownloadStatus();
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
if (status) {
|
const { gameId, progress } = status;
|
||||||
const { gameId, progress } = status;
|
const [download, game] = await Promise.all([
|
||||||
|
downloadsSublevel.get(gameId),
|
||||||
|
gamesSublevel.get(gameId),
|
||||||
|
]);
|
||||||
|
|
||||||
const [download, game] = await Promise.all([
|
if (!download || !game) return;
|
||||||
downloadsSublevel.get(gameId),
|
|
||||||
gamesSublevel.get(gameId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!download || !game) return;
|
this.sendProgressUpdate(progress, status, game);
|
||||||
|
|
||||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
if (progress === 1) {
|
||||||
levelKeys.userPreferences,
|
await this.handleDownloadCompletion(download, game, gameId);
|
||||||
{
|
}
|
||||||
valueEncoding: "json",
|
}
|
||||||
}
|
|
||||||
|
private static sendProgressUpdate(
|
||||||
|
progress: number,
|
||||||
|
status: DownloadProgress,
|
||||||
|
game: any
|
||||||
|
) {
|
||||||
|
if (WindowManager.mainWindow) {
|
||||||
|
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||||
|
WindowManager.mainWindow.webContents.send(
|
||||||
|
"on-download-progress",
|
||||||
|
structuredClone({ ...status, game })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async handleDownloadCompletion(
|
||||||
|
download: Download,
|
||||||
|
game: any,
|
||||||
|
gameId: string
|
||||||
|
) {
|
||||||
|
publishDownloadCompleteNotification(game);
|
||||||
|
|
||||||
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
|
levelKeys.userPreferences,
|
||||||
|
{ valueEncoding: "json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.updateDownloadStatus(
|
||||||
|
download,
|
||||||
|
gameId,
|
||||||
|
userPreferences?.seedAfterDownloadComplete
|
||||||
|
);
|
||||||
|
|
||||||
|
if (download.automaticallyExtract) {
|
||||||
|
this.handleExtraction(download, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.processNextQueuedDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async updateDownloadStatus(
|
||||||
|
download: Download,
|
||||||
|
gameId: string,
|
||||||
|
shouldSeed?: boolean
|
||||||
|
) {
|
||||||
|
const shouldExtract = download.automaticallyExtract;
|
||||||
|
|
||||||
|
if (shouldSeed && download.downloader === Downloader.Torrent) {
|
||||||
|
await downloadsSublevel.put(gameId, {
|
||||||
|
...download,
|
||||||
|
status: "seeding",
|
||||||
|
shouldSeed: true,
|
||||||
|
queued: false,
|
||||||
|
extracting: shouldExtract,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await downloadsSublevel.put(gameId, {
|
||||||
|
...download,
|
||||||
|
status: "complete",
|
||||||
|
shouldSeed: false,
|
||||||
|
queued: false,
|
||||||
|
extracting: shouldExtract,
|
||||||
|
});
|
||||||
|
this.cancelDownload(gameId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static handleExtraction(download: Download, game: any) {
|
||||||
|
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
||||||
|
download.folderName?.endsWith(ext)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
gameFilesManager.extractDownloadedFile();
|
||||||
|
} else if (download.folderName) {
|
||||||
|
gameFilesManager
|
||||||
|
.extractFilesInDirectory(
|
||||||
|
path.join(download.downloadPath, download.folderName)
|
||||||
|
)
|
||||||
|
.then(() => gameFilesManager.setExtractionComplete());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async processNextQueuedDownload() {
|
||||||
|
const downloads = await downloadsSublevel
|
||||||
|
.values()
|
||||||
|
.all()
|
||||||
|
.then((games) =>
|
||||||
|
sortBy(
|
||||||
|
games.filter((game) => game.status === "paused" && game.queued),
|
||||||
|
"timestamp",
|
||||||
|
"DESC"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (WindowManager.mainWindow && download) {
|
const [nextItemOnQueue] = downloads;
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
|
||||||
WindowManager.mainWindow.webContents.send(
|
|
||||||
"on-download-progress",
|
|
||||||
JSON.parse(
|
|
||||||
JSON.stringify({
|
|
||||||
...status,
|
|
||||||
game,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldExtract = download.automaticallyExtract;
|
if (nextItemOnQueue) {
|
||||||
|
this.resumeDownload(nextItemOnQueue);
|
||||||
if (progress === 1 && download) {
|
} else {
|
||||||
publishDownloadCompleteNotification(game);
|
this.downloadingGameId = null;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
if (
|
this.jsDownloader = null;
|
||||||
userPreferences?.seedAfterDownloadComplete &&
|
|
||||||
download.downloader === Downloader.Torrent
|
|
||||||
) {
|
|
||||||
await downloadsSublevel.put(gameId, {
|
|
||||||
...download,
|
|
||||||
status: "seeding",
|
|
||||||
shouldSeed: true,
|
|
||||||
queued: false,
|
|
||||||
extracting: shouldExtract,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await downloadsSublevel.put(gameId, {
|
|
||||||
...download,
|
|
||||||
status: "complete",
|
|
||||||
shouldSeed: false,
|
|
||||||
queued: false,
|
|
||||||
extracting: shouldExtract,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cancelDownload(gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldExtract) {
|
|
||||||
const gameFilesManager = new GameFilesManager(
|
|
||||||
game.shop,
|
|
||||||
game.objectId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
|
||||||
download.folderName?.endsWith(ext)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
gameFilesManager.extractDownloadedFile();
|
|
||||||
} else {
|
|
||||||
gameFilesManager
|
|
||||||
.extractFilesInDirectory(
|
|
||||||
path.join(download.downloadPath, download.folderName!)
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
gameFilesManager.setExtractionComplete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloads = await downloadsSublevel
|
|
||||||
.values()
|
|
||||||
.all()
|
|
||||||
.then((games) => {
|
|
||||||
return sortBy(
|
|
||||||
games.filter((game) => game.status === "paused" && game.queued),
|
|
||||||
"timestamp",
|
|
||||||
"DESC"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [nextItemOnQueue] = downloads;
|
|
||||||
|
|
||||||
if (nextItemOnQueue) {
|
|
||||||
this.resumeDownload(nextItemOnQueue);
|
|
||||||
} else {
|
|
||||||
this.downloadingGameId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,12 +471,17 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
||||||
await PythonRPC.rpc
|
if (this.usingJsDownloader && this.jsDownloader) {
|
||||||
.post("/action", {
|
logger.log("[DownloadManager] Pausing JS download");
|
||||||
action: "pause",
|
this.jsDownloader.pauseDownload();
|
||||||
game_id: downloadKey,
|
} else {
|
||||||
} as PauseDownloadPayload)
|
await PythonRPC.rpc
|
||||||
.catch(() => {});
|
.post("/action", {
|
||||||
|
action: "pause",
|
||||||
|
game_id: downloadKey,
|
||||||
|
} as PauseDownloadPayload)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadKey === this.downloadingGameId) {
|
if (downloadKey === this.downloadingGameId) {
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
@@ -266,19 +494,23 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
||||||
await PythonRPC.rpc
|
if (this.usingJsDownloader && this.jsDownloader) {
|
||||||
.post("/action", {
|
logger.log("[DownloadManager] Cancelling JS download");
|
||||||
action: "cancel",
|
this.jsDownloader.cancelDownload();
|
||||||
game_id: downloadKey,
|
this.jsDownloader = null;
|
||||||
})
|
this.usingJsDownloader = false;
|
||||||
.catch((err) => {
|
} else if (!this.isPreparingDownload) {
|
||||||
logger.error("Failed to cancel game download", err);
|
await PythonRPC.rpc
|
||||||
});
|
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||||
|
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadKey === this.downloadingGameId) {
|
if (downloadKey === this.downloadingGameId) {
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
WindowManager.mainWindow?.setProgressBar(-1);
|
||||||
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
|
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
|
||||||
this.downloadingGameId = null;
|
this.downloadingGameId = null;
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +530,241 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async getJsDownloadOptions(download: Download): Promise<{
|
||||||
|
url: string;
|
||||||
|
savePath: string;
|
||||||
|
filename?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} | null> {
|
||||||
|
const resumingFilename = download.folderName || undefined;
|
||||||
|
|
||||||
|
switch (download.downloader) {
|
||||||
|
case Downloader.Gofile:
|
||||||
|
return this.getGofileDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.PixelDrain:
|
||||||
|
return this.getPixelDrainDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Datanodes:
|
||||||
|
return this.getDatanodesDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Buzzheavier:
|
||||||
|
return this.getBuzzheavierDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.FuckingFast:
|
||||||
|
return this.getFuckingFastDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Mediafire:
|
||||||
|
return this.getMediafireDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.RealDebrid:
|
||||||
|
return this.getRealDebridDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.TorBox:
|
||||||
|
return this.getTorBoxDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Hydra:
|
||||||
|
return this.getHydraDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.VikingFile:
|
||||||
|
return this.getVikingFileDownloadOptions(download, resumingFilename);
|
||||||
|
case Downloader.Rootz:
|
||||||
|
return this.getRootzDownloadOptions(download, resumingFilename);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getGofileDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const id = download.uri.split("/").pop();
|
||||||
|
const token = await GofileApi.authorize();
|
||||||
|
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||||
|
await GofileApi.checkDownloadUrl(downloadLink);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadLink
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadLink,
|
||||||
|
download.downloadPath,
|
||||||
|
filename,
|
||||||
|
{ Cookie: `accountToken=${token}` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getPixelDrainDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const id = download.uri.split("/").pop();
|
||||||
|
const downloadUrl = await PixelDrainApi.getDownloadUrl(id!);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getDatanodesDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getBuzzheavierDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
directUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
directUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getFuckingFastDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
directUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
directUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getMediafireDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getRealDebridDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||||
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getTorBoxDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||||
|
if (!url) return null;
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
url,
|
||||||
|
download.downloadPath,
|
||||||
|
resumingFilename || name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getHydraDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
|
||||||
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getVikingFileDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getRootzDownloadOptions(
|
||||||
|
download: Download,
|
||||||
|
resumingFilename?: string
|
||||||
|
) {
|
||||||
|
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
|
||||||
|
const filename = this.resolveFilename(
|
||||||
|
resumingFilename,
|
||||||
|
download.uri,
|
||||||
|
downloadUrl
|
||||||
|
);
|
||||||
|
return this.buildDownloadOptions(
|
||||||
|
downloadUrl,
|
||||||
|
download.downloadPath,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static async getDownloadPayload(download: Download) {
|
private static async getDownloadPayload(download: Download) {
|
||||||
const downloadId = levelKeys.game(download.shop, download.objectId);
|
const downloadId = levelKeys.game(download.shop, download.objectId);
|
||||||
|
|
||||||
@@ -306,7 +773,6 @@ export class DownloadManager {
|
|||||||
const id = download.uri.split("/").pop();
|
const id = download.uri.split("/").pop();
|
||||||
const token = await GofileApi.authorize();
|
const token = await GofileApi.authorize();
|
||||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||||
|
|
||||||
await GofileApi.checkDownloadUrl(downloadLink);
|
await GofileApi.checkDownloadUrl(downloadLink);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -330,15 +796,6 @@ export class DownloadManager {
|
|||||||
save_path: download.downloadPath,
|
save_path: download.downloadPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case Downloader.Qiwi: {
|
|
||||||
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
|
|
||||||
return {
|
|
||||||
action: "start",
|
|
||||||
game_id: downloadId,
|
|
||||||
url: downloadUrl,
|
|
||||||
save_path: download.downloadPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case Downloader.Datanodes: {
|
case Downloader.Datanodes: {
|
||||||
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
||||||
return {
|
return {
|
||||||
@@ -348,9 +805,50 @@ export class DownloadManager {
|
|||||||
save_path: download.downloadPath,
|
save_path: download.downloadPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case Downloader.Buzzheavier: {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||||
|
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
||||||
|
return this.createDownloadPayload(
|
||||||
|
directUrl,
|
||||||
|
download.uri,
|
||||||
|
downloadId,
|
||||||
|
download.downloadPath
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[DownloadManager] Error processing Buzzheavier download:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case Downloader.FuckingFast: {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||||
|
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
||||||
|
return this.createDownloadPayload(
|
||||||
|
directUrl,
|
||||||
|
download.uri,
|
||||||
|
downloadId,
|
||||||
|
download.downloadPath
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[DownloadManager] Error processing FuckingFast download:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
case Downloader.Mediafire: {
|
case Downloader.Mediafire: {
|
||||||
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action: "start",
|
||||||
game_id: downloadId,
|
game_id: downloadId,
|
||||||
@@ -367,7 +865,6 @@ export class DownloadManager {
|
|||||||
};
|
};
|
||||||
case Downloader.RealDebrid: {
|
case Downloader.RealDebrid: {
|
||||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -380,7 +877,6 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
case Downloader.TorBox: {
|
case Downloader.TorBox: {
|
||||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||||
|
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
return {
|
return {
|
||||||
action: "start",
|
action: "start",
|
||||||
@@ -395,7 +891,6 @@ export class DownloadManager {
|
|||||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||||
download.uri
|
download.uri
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -406,12 +901,75 @@ export class DownloadManager {
|
|||||||
allow_multiple_connections: true,
|
allow_multiple_connections: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case Downloader.VikingFile: {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
|
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
|
||||||
|
return this.createDownloadPayload(
|
||||||
|
downloadUrl,
|
||||||
|
download.uri,
|
||||||
|
downloadId,
|
||||||
|
download.downloadPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case Downloader.Rootz: {
|
||||||
|
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
|
||||||
|
return {
|
||||||
|
action: "start",
|
||||||
|
game_id: downloadId,
|
||||||
|
url: downloadUrl,
|
||||||
|
save_path: download.downloadPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async startDownload(download: Download) {
|
static async startDownload(download: Download) {
|
||||||
const payload = await this.getDownloadPayload(download);
|
const useJsDownloader = await this.shouldUseJsDownloader();
|
||||||
await PythonRPC.rpc.post("/action", payload);
|
const isHttp = this.isHttpDownloader(download.downloader);
|
||||||
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
|
const downloadId = levelKeys.game(download.shop, download.objectId);
|
||||||
|
|
||||||
|
if (useJsDownloader && isHttp) {
|
||||||
|
logger.log("[DownloadManager] Using JS HTTP downloader");
|
||||||
|
|
||||||
|
// Set preparing state immediately so UI knows download is starting
|
||||||
|
this.downloadingGameId = downloadId;
|
||||||
|
this.isPreparingDownload = true;
|
||||||
|
this.usingJsDownloader = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await this.getJsDownloadOptions(download);
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
this.downloadingGameId = null;
|
||||||
|
throw new Error("Failed to get download options for JS downloader");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jsDownloader = new JsHttpDownloader();
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
|
||||||
|
this.jsDownloader.startDownload(options).catch((err) => {
|
||||||
|
logger.error("[DownloadManager] JS download error:", err);
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
this.jsDownloader = null;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.isPreparingDownload = false;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
this.downloadingGameId = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log("[DownloadManager] Using Python RPC downloader");
|
||||||
|
const payload = await this.getDownloadPayload(download);
|
||||||
|
await PythonRPC.rpc.post("/action", payload);
|
||||||
|
this.downloadingGameId = downloadId;
|
||||||
|
this.usingJsDownloader = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
380
src/main/services/download/js-http-downloader.ts
Normal file
380
src/main/services/download/js-http-downloader.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,58 @@ import fs from "node:fs";
|
|||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||||
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
|
||||||
import { SevenZip } from "./7zip";
|
import { SevenZip, ExtractionProgress } from "./7zip";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { publishExtractionCompleteNotification } from "./notifications";
|
import { publishExtractionCompleteNotification } from "./notifications";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
const PROGRESS_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
export class GameFilesManager {
|
export class GameFilesManager {
|
||||||
|
private lastProgressUpdate = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly shop: GameShop,
|
private readonly shop: GameShop,
|
||||||
private readonly objectId: string
|
private readonly objectId: string
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async clearExtractionState() {
|
private get gameKey() {
|
||||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
return levelKeys.game(this.shop, this.objectId);
|
||||||
const download = await downloadsSublevel.get(gameKey);
|
}
|
||||||
|
|
||||||
await downloadsSublevel.put(gameKey, {
|
private async updateExtractionProgress(progress: number, force = false) {
|
||||||
...download!,
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastProgressUpdate = now;
|
||||||
|
|
||||||
|
const download = await downloadsSublevel.get(this.gameKey);
|
||||||
|
if (!download) return;
|
||||||
|
|
||||||
|
await downloadsSublevel.put(this.gameKey, {
|
||||||
|
...download,
|
||||||
|
extractionProgress: progress,
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
"on-extraction-progress",
|
||||||
|
this.shop,
|
||||||
|
this.objectId,
|
||||||
|
progress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearExtractionState() {
|
||||||
|
const download = await downloadsSublevel.get(this.gameKey);
|
||||||
|
if (!download) return;
|
||||||
|
|
||||||
|
await downloadsSublevel.put(this.gameKey, {
|
||||||
|
...download,
|
||||||
extracting: false,
|
extracting: false,
|
||||||
|
extractionProgress: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
WindowManager.mainWindow?.webContents.send(
|
||||||
@@ -30,6 +64,10 @@ export class GameFilesManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly handleProgress = (progress: ExtractionProgress) => {
|
||||||
|
this.updateExtractionProgress(progress.percent / 100);
|
||||||
|
};
|
||||||
|
|
||||||
async extractFilesInDirectory(directoryPath: string) {
|
async extractFilesInDirectory(directoryPath: string) {
|
||||||
if (!fs.existsSync(directoryPath)) return;
|
if (!fs.existsSync(directoryPath)) return;
|
||||||
const files = await fs.promises.readdir(directoryPath);
|
const files = await fs.promises.readdir(directoryPath);
|
||||||
@@ -42,53 +80,66 @@ export class GameFilesManager {
|
|||||||
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
|
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(
|
if (filesToExtract.length === 0) return;
|
||||||
filesToExtract.map((file) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
SevenZip.extractFile(
|
|
||||||
{
|
|
||||||
filePath: path.join(directoryPath, file),
|
|
||||||
cwd: directoryPath,
|
|
||||||
passwords: ["online-fix.me", "steamrip.com"],
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
reject(new Error(`Failed to extract file: ${file}`));
|
|
||||||
this.clearExtractionState();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
compressedFiles.forEach((file) => {
|
await this.updateExtractionProgress(0, true);
|
||||||
const extractionPath = path.join(directoryPath, file);
|
|
||||||
|
|
||||||
if (fs.existsSync(extractionPath)) {
|
const totalFiles = filesToExtract.length;
|
||||||
fs.unlink(extractionPath, (err) => {
|
let completedFiles = 0;
|
||||||
if (err) {
|
|
||||||
logger.error(`Failed to delete file: ${file}`, err);
|
|
||||||
|
|
||||||
this.clearExtractionState();
|
for (const file of filesToExtract) {
|
||||||
|
try {
|
||||||
|
const result = await SevenZip.extractFile(
|
||||||
|
{
|
||||||
|
filePath: path.join(directoryPath, file),
|
||||||
|
cwd: directoryPath,
|
||||||
|
passwords: ["online-fix.me", "steamrip.com"],
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
const overallProgress =
|
||||||
|
(completedFiles + progress.percent / 100) / totalFiles;
|
||||||
|
this.updateExtractionProgress(overallProgress);
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
completedFiles++;
|
||||||
|
await this.updateExtractionProgress(
|
||||||
|
completedFiles / totalFiles,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to extract file: ${file}`, err);
|
||||||
|
await this.clearExtractionState();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const archivePaths = compressedFiles
|
||||||
|
.map((file) => path.join(directoryPath, file))
|
||||||
|
.filter((archivePath) => fs.existsSync(archivePath));
|
||||||
|
|
||||||
|
if (archivePaths.length > 0) {
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
"on-archive-deletion-prompt",
|
||||||
|
archivePaths
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setExtractionComplete(publishNotification = true) {
|
async setExtractionComplete(publishNotification = true) {
|
||||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
|
||||||
|
|
||||||
const [download, game] = await Promise.all([
|
const [download, game] = await Promise.all([
|
||||||
downloadsSublevel.get(gameKey),
|
downloadsSublevel.get(this.gameKey),
|
||||||
gamesSublevel.get(gameKey),
|
gamesSublevel.get(this.gameKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await downloadsSublevel.put(gameKey, {
|
if (!download) return;
|
||||||
...download!,
|
|
||||||
|
await downloadsSublevel.put(this.gameKey, {
|
||||||
|
...download,
|
||||||
extracting: false,
|
extracting: false,
|
||||||
|
extractionProgress: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
WindowManager.mainWindow?.webContents.send(
|
||||||
@@ -97,17 +148,15 @@ export class GameFilesManager {
|
|||||||
this.objectId
|
this.objectId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (publishNotification) {
|
if (publishNotification && game) {
|
||||||
publishExtractionCompleteNotification(game!);
|
publishExtractionCompleteNotification(game);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractDownloadedFile() {
|
async extractDownloadedFile() {
|
||||||
const gameKey = levelKeys.game(this.shop, this.objectId);
|
|
||||||
|
|
||||||
const [download, game] = await Promise.all([
|
const [download, game] = await Promise.all([
|
||||||
downloadsSublevel.get(gameKey),
|
downloadsSublevel.get(this.gameKey),
|
||||||
gamesSublevel.get(gameKey),
|
gamesSublevel.get(this.gameKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!download || !game) return false;
|
if (!download || !game) return false;
|
||||||
@@ -119,39 +168,39 @@ export class GameFilesManager {
|
|||||||
path.parse(download.folderName!).name
|
path.parse(download.folderName!).name
|
||||||
);
|
);
|
||||||
|
|
||||||
SevenZip.extractFile(
|
await this.updateExtractionProgress(0, true);
|
||||||
{
|
|
||||||
filePath,
|
try {
|
||||||
outputPath: extractionPath,
|
const result = await SevenZip.extractFile(
|
||||||
passwords: ["online-fix.me", "steamrip.com"],
|
{
|
||||||
},
|
filePath,
|
||||||
async () => {
|
outputPath: extractionPath,
|
||||||
|
passwords: ["online-fix.me", "steamrip.com"],
|
||||||
|
},
|
||||||
|
this.handleProgress
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
await this.extractFilesInDirectory(extractionPath);
|
await this.extractFilesInDirectory(extractionPath);
|
||||||
|
|
||||||
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
|
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
|
||||||
fs.unlink(filePath, (err) => {
|
WindowManager.mainWindow?.webContents.send(
|
||||||
if (err) {
|
"on-archive-deletion-prompt",
|
||||||
logger.error(
|
[filePath]
|
||||||
`Failed to delete file: ${download.folderName}`,
|
);
|
||||||
err
|
|
||||||
);
|
|
||||||
|
|
||||||
this.clearExtractionState();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await downloadsSublevel.put(gameKey, {
|
await downloadsSublevel.put(this.gameKey, {
|
||||||
...download!,
|
...download,
|
||||||
folderName: path.parse(download.folderName!).name,
|
folderName: path.parse(download.folderName!).name,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setExtractionComplete();
|
await this.setExtractionComplete();
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.clearExtractionState();
|
|
||||||
}
|
}
|
||||||
);
|
} catch (err) {
|
||||||
|
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
|
||||||
|
await this.clearExtractionState();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/main/services/hosters/buzzheavier.ts
Normal file
100
src/main/services/hosters/buzzheavier.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import {
|
||||||
|
HOSTER_USER_AGENT,
|
||||||
|
extractHosterFilename,
|
||||||
|
handleHosterError,
|
||||||
|
} from "./fuckingfast";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
export class BuzzheavierApi {
|
||||||
|
private static readonly BUZZHEAVIER_DOMAINS = [
|
||||||
|
"buzzheavier.com",
|
||||||
|
"bzzhr.co",
|
||||||
|
"fuckingfast.net",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static isSupportedDomain(url: string): boolean {
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const baseUrl = url.split("#")[0];
|
||||||
|
logger.log(
|
||||||
|
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await axios.get(baseUrl, {
|
||||||
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
|
timeout: 30000,
|
||||||
|
httpAgent: new http.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
httpsAgent: new https.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadUrl = `${baseUrl}/download`;
|
||||||
|
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
|
||||||
|
const headResponse = await axios.head(downloadUrl, {
|
||||||
|
headers: {
|
||||||
|
"hx-current-url": baseUrl,
|
||||||
|
"hx-request": "true",
|
||||||
|
referer: baseUrl,
|
||||||
|
"User-Agent": HOSTER_USER_AGENT,
|
||||||
|
},
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) =>
|
||||||
|
status === 200 || status === 204 || status === 301 || status === 302,
|
||||||
|
timeout: 30000,
|
||||||
|
httpAgent: new http.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
httpsAgent: new https.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||||
|
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||||
|
if (!hxRedirect) {
|
||||||
|
logger.error(
|
||||||
|
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
"Could not extract download link. File may be deleted or is a directory."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = new URL(baseUrl).hostname;
|
||||||
|
const directLink = hxRedirect.startsWith("/dl/")
|
||||||
|
? `https://${domain}${hxRedirect}`
|
||||||
|
: hxRedirect;
|
||||||
|
logger.log(`[Buzzheavier] Extracted direct link`);
|
||||||
|
return directLink;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
|
||||||
|
handleHosterError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getDirectLink(url: string): Promise<string> {
|
||||||
|
if (!this.isSupportedDomain(url)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getBuzzheavierDirectLink(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getFilename(
|
||||||
|
url: string,
|
||||||
|
directUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
return extractHosterFilename(url, directUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/main/services/hosters/fuckingfast.ts
Normal file
129
src/main/services/hosters/fuckingfast.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
export const HOSTER_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
|
||||||
|
|
||||||
|
export async function extractHosterFilename(
|
||||||
|
url: string,
|
||||||
|
directUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (url.includes("#")) {
|
||||||
|
const fragment = url.split("#")[1];
|
||||||
|
if (fragment && !fragment.startsWith("http")) {
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directUrl) {
|
||||||
|
try {
|
||||||
|
const response = await axios.head(directUrl, {
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentDisposition = response.headers["content-disposition"];
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
|
||||||
|
contentDisposition
|
||||||
|
);
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
return filenameMatch[1].replace(/['"]/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlPath = new URL(directUrl).pathname;
|
||||||
|
const filename = urlPath.split("/").pop()?.split("?")[0];
|
||||||
|
if (filename) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "downloaded_file";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleHosterError(error: unknown): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
throw new Error("File not found");
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
throw new Error("Rate limit exceeded. Please try again later.");
|
||||||
|
}
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
throw new Error("Access denied. File may be private or deleted.");
|
||||||
|
}
|
||||||
|
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FuckingFast API Class
|
||||||
|
// ============================================
|
||||||
|
export class FuckingFastApi {
|
||||||
|
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
|
||||||
|
|
||||||
|
private static readonly FUCKINGFAST_REGEX =
|
||||||
|
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
|
||||||
|
|
||||||
|
private static isSupportedDomain(url: string): boolean {
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getFuckingFastDirectLink(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = response.data;
|
||||||
|
|
||||||
|
if (html.toLowerCase().includes("rate limit")) {
|
||||||
|
logger.error(`[FuckingFast] Rate limit detected`);
|
||||||
|
throw new Error(
|
||||||
|
"Rate limit exceeded. Please wait a few minutes and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.includes("File Not Found Or Deleted")) {
|
||||||
|
logger.error(`[FuckingFast] File not found or deleted`);
|
||||||
|
throw new Error("File not found or deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = this.FUCKINGFAST_REGEX.exec(html);
|
||||||
|
if (!match || !match[1]) {
|
||||||
|
logger.error(`[FuckingFast] Could not extract download link`);
|
||||||
|
throw new Error("Could not extract download link from page");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[FuckingFast] Successfully extracted direct link`);
|
||||||
|
return match[1];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[FuckingFast] Error:`, error);
|
||||||
|
handleHosterError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getDirectLink(url: string): Promise<string> {
|
||||||
|
if (!this.isSupportedDomain(url)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getFuckingFastDirectLink(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getFilename(
|
||||||
|
url: string,
|
||||||
|
directUrl?: string
|
||||||
|
): Promise<string> {
|
||||||
|
return extractHosterFilename(url, directUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,16 +36,13 @@ export class GofileApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async getDownloadLink(id: string) {
|
public static async getDownloadLink(id: string) {
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
wt: WT,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await axios.get<{
|
const response = await axios.get<{
|
||||||
status: string;
|
status: string;
|
||||||
data: GofileContentsResponse;
|
data: GofileContentsResponse;
|
||||||
}>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
|
}>(`https://api.gofile.io/contents/${id}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.token}`,
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"X-Website-Token": WT,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export * from "./gofile";
|
export * from "./gofile";
|
||||||
export * from "./qiwi";
|
|
||||||
export * from "./datanodes";
|
export * from "./datanodes";
|
||||||
export * from "./mediafire";
|
export * from "./mediafire";
|
||||||
export * from "./pixeldrain";
|
export * from "./pixeldrain";
|
||||||
|
export * from "./buzzheavier";
|
||||||
|
export * from "./fuckingfast";
|
||||||
|
export * from "./vikingfile";
|
||||||
|
export * from "./rootz";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
src/main/services/hosters/rootz.ts
Normal file
58
src/main/services/hosters/rootz.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/services/hosters/vikingfile.ts
Normal file
46
src/main/services/hosters/vikingfile.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
interface UnlockResponse {
|
||||||
|
link: string;
|
||||||
|
hoster: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VikingFileApi {
|
||||||
|
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||||
|
const unlockResponse = await axios.post<UnlockResponse>(
|
||||||
|
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
|
||||||
|
{ url: uri }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unlockResponse.data.link) {
|
||||||
|
throw new Error("Failed to unlock VikingFile URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = unlockResponse.data.link;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectResponse = await axios.head(redirectUrl, {
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) =>
|
||||||
|
status === 301 || status === 302 || status === 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
redirectResponse.headers.location ||
|
||||||
|
redirectResponse.status === 301 ||
|
||||||
|
redirectResponse.status === 302
|
||||||
|
) {
|
||||||
|
return redirectResponse.headers.location || redirectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirectUrl;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[VikingFile] Error following redirect, using redirect URL:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return redirectUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
|||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
|
|
||||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||||
|
|
||||||
private static secondsToMilliseconds(seconds: number) {
|
private static secondsToMilliseconds(seconds: number) {
|
||||||
return seconds * 1000;
|
return seconds * 1000;
|
||||||
@@ -58,7 +58,13 @@ export class HydraApi {
|
|||||||
const decodedBase64 = atob(payload as string);
|
const decodedBase64 = atob(payload as string);
|
||||||
const jsonData = JSON.parse(decodedBase64);
|
const jsonData = JSON.parse(decodedBase64);
|
||||||
|
|
||||||
const { accessToken, expiresIn, refreshToken } = jsonData;
|
const {
|
||||||
|
accessToken,
|
||||||
|
expiresIn,
|
||||||
|
refreshToken,
|
||||||
|
featurebaseJwt,
|
||||||
|
workwondersJwt,
|
||||||
|
} = jsonData;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -85,6 +91,8 @@ export class HydraApi {
|
|||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
tokenExpirationTimestamp,
|
tokenExpirationTimestamp,
|
||||||
|
featurebaseJwt,
|
||||||
|
workwondersJwt,
|
||||||
},
|
},
|
||||||
{ valueEncoding: "json" }
|
{ valueEncoding: "json" }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ export * from "./lock";
|
|||||||
export * from "./decky-plugin";
|
export * from "./decky-plugin";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./download-sources-checker";
|
export * from "./download-sources-checker";
|
||||||
|
export * from "./notifications/local-notifications";
|
||||||
|
|||||||
87
src/main/services/node-7z.d.ts
vendored
Normal file
87
src/main/services/node-7z.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
declare module "node-7z" {
|
||||||
|
import { ChildProcess } from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
export interface CommandLineSwitches {
|
||||||
|
$bin?: string;
|
||||||
|
$progress?: boolean;
|
||||||
|
$spawnOptions?: {
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
outputDir?: string;
|
||||||
|
yes?: boolean;
|
||||||
|
password?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressInfo {
|
||||||
|
percent: number;
|
||||||
|
fileCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
file?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZipStream extends EventEmitter {
|
||||||
|
on(event: "progress", listener: (progress: ProgressInfo) => void): this;
|
||||||
|
on(event: "data", listener: (data: FileInfo) => void): this;
|
||||||
|
on(event: "end", listener: () => void): this;
|
||||||
|
on(event: "error", listener: (err: Error) => void): this;
|
||||||
|
info: Map<string, unknown>;
|
||||||
|
_childProcess?: ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFull(
|
||||||
|
archive: string,
|
||||||
|
output: string,
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
export function extract(
|
||||||
|
archive: string,
|
||||||
|
output: string,
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
export function list(
|
||||||
|
archive: string,
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
export function add(
|
||||||
|
archive: string,
|
||||||
|
files: string | string[],
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
export function update(
|
||||||
|
archive: string,
|
||||||
|
files: string | string[],
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
export function deleteFiles(
|
||||||
|
archive: string,
|
||||||
|
files: string | string[],
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
export function test(
|
||||||
|
archive: string,
|
||||||
|
options?: CommandLineSwitches
|
||||||
|
): ZipStream;
|
||||||
|
|
||||||
|
const Seven: {
|
||||||
|
extractFull: typeof extractFull;
|
||||||
|
extract: typeof extract;
|
||||||
|
list: typeof list;
|
||||||
|
add: typeof add;
|
||||||
|
update: typeof update;
|
||||||
|
delete: typeof deleteFiles;
|
||||||
|
test: typeof test;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Seven;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
|
|||||||
import { SystemPath } from "../system-path";
|
import { SystemPath } from "../system-path";
|
||||||
import { getThemeSoundPath } from "@main/helpers";
|
import { getThemeSoundPath } from "@main/helpers";
|
||||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||||
|
import { LocalNotificationManager } from "./local-notifications";
|
||||||
|
|
||||||
const getStaticImage = async (path: string) => {
|
const getStaticImage = async (path: string) => {
|
||||||
return processProfileImage(path, "jpg")
|
return processProfileImage(path, "jpg")
|
||||||
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const title = t("download_complete", { ns: "notifications" });
|
||||||
|
const body = t("game_ready_to_install", {
|
||||||
|
ns: "notifications",
|
||||||
|
title: game.title,
|
||||||
|
});
|
||||||
|
|
||||||
if (userPreferences?.downloadNotificationsEnabled) {
|
if (userPreferences?.downloadNotificationsEnabled) {
|
||||||
new Notification({
|
new Notification({
|
||||||
title: t("download_complete", {
|
title,
|
||||||
ns: "notifications",
|
body,
|
||||||
}),
|
|
||||||
body: t("game_ready_to_install", {
|
|
||||||
ns: "notifications",
|
|
||||||
title: game.title,
|
|
||||||
}),
|
|
||||||
icon: await downloadImage(game.iconUrl),
|
icon: await downloadImage(game.iconUrl),
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"DOWNLOAD_COMPLETE",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
pictureUrl: game.iconUrl,
|
||||||
|
url: `/game/${game.shop}/${game.objectId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNotificationUpdateReadyToInstall = async (
|
export const publishNotificationUpdateReadyToInstall = async (
|
||||||
version: string
|
version: string
|
||||||
) => {
|
) => {
|
||||||
|
const title = t("new_update_available", {
|
||||||
|
ns: "notifications",
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
const body = t("restart_to_install_update", {
|
||||||
|
ns: "notifications",
|
||||||
|
});
|
||||||
|
|
||||||
new Notification({
|
new Notification({
|
||||||
title: t("new_update_available", {
|
title,
|
||||||
ns: "notifications",
|
body,
|
||||||
version,
|
|
||||||
}),
|
|
||||||
body: t("restart_to_install_update", {
|
|
||||||
ns: "notifications",
|
|
||||||
}),
|
|
||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
})
|
})
|
||||||
.on("click", () => {
|
.on("click", () => {
|
||||||
restartAndInstallUpdate();
|
restartAndInstallUpdate();
|
||||||
})
|
})
|
||||||
.show();
|
.show();
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"UPDATE_AVAILABLE",
|
||||||
|
title,
|
||||||
|
body
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNewFriendRequestNotification = async (
|
export const publishNewFriendRequestNotification = async (
|
||||||
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||||
|
const title = t("extraction_complete", { ns: "notifications" });
|
||||||
|
const body = t("game_extracted", {
|
||||||
|
ns: "notifications",
|
||||||
|
title: game.title,
|
||||||
|
});
|
||||||
|
|
||||||
new Notification({
|
new Notification({
|
||||||
title: t("extraction_complete", { ns: "notifications" }),
|
title,
|
||||||
body: t("game_extracted", {
|
body,
|
||||||
ns: "notifications",
|
|
||||||
title: game.title,
|
|
||||||
}),
|
|
||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
}).show();
|
}).show();
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"EXTRACTION_COMPLETE",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
url: `/game/${game.shop}/${game.objectId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNewAchievementNotification = async (info: {
|
export const publishNewAchievementNotification = async (info: {
|
||||||
|
|||||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { localNotificationsSublevel } from "@main/level";
|
||||||
|
import { WindowManager } from "../window-manager";
|
||||||
|
import type { LocalNotification, LocalNotificationType } from "@types";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export class LocalNotificationManager {
|
||||||
|
private static generateId(): string {
|
||||||
|
return crypto.randomBytes(8).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createNotification(
|
||||||
|
type: LocalNotificationType,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
options?: {
|
||||||
|
pictureUrl?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<LocalNotification> {
|
||||||
|
const id = this.generateId();
|
||||||
|
const notification: LocalNotification = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
pictureUrl: options?.pictureUrl ?? null,
|
||||||
|
url: options?.url ?? null,
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await localNotificationsSublevel.put(id, notification);
|
||||||
|
|
||||||
|
// Notify renderer about new notification
|
||||||
|
if (WindowManager.mainWindow) {
|
||||||
|
WindowManager.mainWindow.webContents.send(
|
||||||
|
"on-local-notification-created",
|
||||||
|
notification
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getNotifications(): Promise<LocalNotification[]> {
|
||||||
|
const notifications: LocalNotification[] = [];
|
||||||
|
|
||||||
|
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||||
|
notifications.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending
|
||||||
|
return notifications.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUnreadCount(): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||||
|
if (!value.isRead) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markAsRead(id: string): Promise<void> {
|
||||||
|
const notification = await localNotificationsSublevel.get(id);
|
||||||
|
if (notification) {
|
||||||
|
notification.isRead = true;
|
||||||
|
await localNotificationsSublevel.put(id, notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markAllAsRead(): Promise<void> {
|
||||||
|
const batch = localNotificationsSublevel.batch();
|
||||||
|
|
||||||
|
for await (const [key, value] of localNotificationsSublevel.iterator()) {
|
||||||
|
if (!value.isRead) {
|
||||||
|
value.isRead = true;
|
||||||
|
batch.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.write();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteNotification(id: string): Promise<void> {
|
||||||
|
await localNotificationsSublevel.del(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async clearAll(): Promise<void> {
|
||||||
|
await localNotificationsSublevel.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import http from "node:http";
|
||||||
|
|
||||||
import cp from "node:child_process";
|
import cp from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -31,6 +32,9 @@ export class PythonRPC {
|
|||||||
public static readonly RPC_PORT = "8084";
|
public static readonly RPC_PORT = "8084";
|
||||||
public static readonly rpc = axios.create({
|
public static readonly rpc = axios.create({
|
||||||
baseURL: `http://localhost:${this.RPC_PORT}`,
|
baseURL: `http://localhost:${this.RPC_PORT}`,
|
||||||
|
httpAgent: new http.Agent({
|
||||||
|
family: 4, // Force IPv4
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
private static pythonProcess: cp.ChildProcess | null = null;
|
private static pythonProcess: cp.ChildProcess | null = null;
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export class WindowManager {
|
|||||||
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
||||||
{
|
{
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 720,
|
height: 860,
|
||||||
minWidth: 1024,
|
minWidth: 1024,
|
||||||
minHeight: 540,
|
minHeight: 860,
|
||||||
backgroundColor: "#1c1c1c",
|
backgroundColor: "#1c1c1c",
|
||||||
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
||||||
icon,
|
icon,
|
||||||
@@ -106,7 +106,7 @@ export class WindowManager {
|
|||||||
valueEncoding: "json",
|
valueEncoding: "json",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return data ?? { isMaximized: false, height: 720, width: 1200 };
|
return data ?? { isMaximized: false, height: 860, width: 1200 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static updateInitialConfig(
|
private static updateInitialConfig(
|
||||||
@@ -143,6 +143,16 @@ export class WindowManager {
|
|||||||
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({
|
||||||
@@ -159,7 +169,8 @@ export class WindowManager {
|
|||||||
if (
|
if (
|
||||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||||
details.url.includes("featurebase") ||
|
details.url.includes("featurebase") ||
|
||||||
details.url.includes("chatwoot")
|
details.url.includes("chatwoot") ||
|
||||||
|
details.url.includes("workwonders")
|
||||||
) {
|
) {
|
||||||
return callback(details);
|
return callback(details);
|
||||||
}
|
}
|
||||||
@@ -222,7 +233,7 @@ export class WindowManager {
|
|||||||
? {
|
? {
|
||||||
x: undefined,
|
x: undefined,
|
||||||
y: undefined,
|
y: undefined,
|
||||||
height: this.initialConfigInitializationMainWindow.height ?? 720,
|
height: this.initialConfigInitializationMainWindow.height ?? 860,
|
||||||
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
||||||
isMaximized: true,
|
isMaximized: true,
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Notification } from "@main/generated/envelope";
|
||||||
|
import { WindowManager } from "@main/services/window-manager";
|
||||||
|
|
||||||
|
export const notificationEvent = (payload: Notification) => {
|
||||||
|
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
|
||||||
|
notificationCount: payload.notificationCount,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
|
|||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { friendRequestEvent } from "./events/friend-request";
|
import { friendRequestEvent } from "./events/friend-request";
|
||||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||||
|
import { notificationEvent } from "./events/notification";
|
||||||
|
|
||||||
export class WSClient {
|
export class WSClient {
|
||||||
private static ws: WebSocket | null = null;
|
private static ws: WebSocket | null = null;
|
||||||
@@ -51,6 +52,10 @@ export class WSClient {
|
|||||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envelope.payload.oneofKind === "notification") {
|
||||||
|
notificationEvent(envelope.payload.notification);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||||
|
|||||||
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
|||||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
readonly MAIN_VITE_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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
GameAchievement,
|
GameAchievement,
|
||||||
Theme,
|
Theme,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
ShortcutLocation,
|
ShortcutLocation,
|
||||||
AchievementCustomNotificationPosition,
|
AchievementCustomNotificationPosition,
|
||||||
AchievementNotificationInfo,
|
AchievementNotificationInfo,
|
||||||
@@ -205,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
||||||
openGameInstaller: (shop: GameShop, objectId: string) =>
|
openGameInstaller: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
||||||
|
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
|
||||||
|
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
|
||||||
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
|
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
|
||||||
openGameExecutablePath: (shop: GameShop, objectId: string) =>
|
openGameExecutablePath: (shop: GameShop, objectId: string) =>
|
||||||
@@ -267,6 +270,29 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.on("on-extraction-complete", listener);
|
ipcRenderer.on("on-extraction-complete", listener);
|
||||||
return () => ipcRenderer.removeListener("on-extraction-complete", listener);
|
return () => ipcRenderer.removeListener("on-extraction-complete", listener);
|
||||||
},
|
},
|
||||||
|
onExtractionProgress: (
|
||||||
|
cb: (shop: GameShop, objectId: string, progress: number) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string,
|
||||||
|
progress: number
|
||||||
|
) => cb(shop, objectId, progress);
|
||||||
|
ipcRenderer.on("on-extraction-progress", listener);
|
||||||
|
return () => ipcRenderer.removeListener("on-extraction-progress", listener);
|
||||||
|
},
|
||||||
|
onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
archivePaths: string[]
|
||||||
|
) => cb(archivePaths);
|
||||||
|
ipcRenderer.on("on-archive-deletion-prompt", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-archive-deletion-prompt", listener);
|
||||||
|
},
|
||||||
|
deleteArchive: (filePath: string) =>
|
||||||
|
ipcRenderer.invoke("deleteArchive", filePath),
|
||||||
|
|
||||||
/* Hardware */
|
/* Hardware */
|
||||||
getDiskFreeSpace: (path: string) =>
|
getDiskFreeSpace: (path: string) =>
|
||||||
@@ -474,7 +500,6 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("updateProfile", updateProfile),
|
ipcRenderer.invoke("updateProfile", updateProfile),
|
||||||
processProfileImage: (imagePath: string) =>
|
processProfileImage: (imagePath: string) =>
|
||||||
ipcRenderer.invoke("processProfileImage", imagePath),
|
ipcRenderer.invoke("processProfileImage", imagePath),
|
||||||
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
|
|
||||||
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
@@ -484,6 +509,15 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
return () =>
|
return () =>
|
||||||
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
||||||
},
|
},
|
||||||
|
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
notification: NotificationSync
|
||||||
|
) => cb(notification);
|
||||||
|
ipcRenderer.on("on-sync-notification-count", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-sync-notification-count", listener);
|
||||||
|
},
|
||||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||||
|
|
||||||
@@ -527,6 +561,26 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
/* Notifications */
|
/* Notifications */
|
||||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
||||||
|
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
|
||||||
|
getLocalNotificationsCount: () =>
|
||||||
|
ipcRenderer.invoke("getLocalNotificationsCount"),
|
||||||
|
markLocalNotificationRead: (id: string) =>
|
||||||
|
ipcRenderer.invoke("markLocalNotificationRead", id),
|
||||||
|
markAllLocalNotificationsRead: () =>
|
||||||
|
ipcRenderer.invoke("markAllLocalNotificationsRead"),
|
||||||
|
deleteLocalNotification: (id: string) =>
|
||||||
|
ipcRenderer.invoke("deleteLocalNotification", id),
|
||||||
|
clearAllLocalNotifications: () =>
|
||||||
|
ipcRenderer.invoke("clearAllLocalNotifications"),
|
||||||
|
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
notification: unknown
|
||||||
|
) => cb(notification);
|
||||||
|
ipcRenderer.on("on-local-notification-created", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-local-notification-created", listener);
|
||||||
|
},
|
||||||
onAchievementUnlocked: (
|
onAchievementUnlocked: (
|
||||||
cb: (
|
cb: (
|
||||||
position?: AchievementCustomNotificationPosition,
|
position?: AchievementCustomNotificationPosition,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Hydra Launcher</title>
|
<title>Hydra Launcher</title>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self' 'unsafe-inline' * data: local:;"
|
content="default-src 'self' 'unsafe-inline' * data: local:; media-src 'self' 'unsafe-inline' * data: local: blob:;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||||
|
import { WorkWondersSdk } from "workwonders-sdk";
|
||||||
import {
|
import {
|
||||||
useAppDispatch,
|
useAppDispatch,
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
@@ -19,11 +19,13 @@ import {
|
|||||||
setUserDetails,
|
setUserDetails,
|
||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
setGameRunning,
|
setGameRunning,
|
||||||
|
setExtractionProgress,
|
||||||
|
clearExtraction,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
|
||||||
import { useSubscription } from "./hooks/use-subscription";
|
import { useSubscription } from "./hooks/use-subscription";
|
||||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||||
|
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
injectCustomCss,
|
injectCustomCss,
|
||||||
@@ -50,13 +52,10 @@ export function App() {
|
|||||||
|
|
||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|
||||||
|
const workwondersRef = useRef<WorkWondersSdk | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userDetails,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
isFriendsModalVisible,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
friendModalUserId,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
updateUserDetails,
|
updateUserDetails,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
@@ -78,6 +77,10 @@ export function App() {
|
|||||||
|
|
||||||
const { showSuccessToast } = useToast();
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
|
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
|
||||||
|
useState(false);
|
||||||
|
const [archivePaths, setArchivePaths] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
levelDBService.get("userPreferences", null, "json"),
|
levelDBService.get("userPreferences", null, "json"),
|
||||||
@@ -113,7 +116,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 WorkWondersSdk();
|
||||||
|
await workwondersRef.current.init({
|
||||||
|
organization: "hydra",
|
||||||
|
token,
|
||||||
|
locale: parsedLocale,
|
||||||
|
});
|
||||||
|
|
||||||
|
await workwondersRef.current.initChangelogWidget();
|
||||||
|
workwondersRef.current.initChangelogWidgetMini();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
workwondersRef.current.initFeedbackWidget();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workwondersRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupExternalResources = useCallback(async () => {
|
||||||
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
const cachedUserDetails = window.localStorage.getItem("userDetails");
|
||||||
|
|
||||||
if (cachedUserDetails) {
|
if (cachedUserDetails) {
|
||||||
@@ -124,28 +153,31 @@ export function App() {
|
|||||||
dispatch(setProfileBackground(profileBackground));
|
dispatch(setProfileBackground(profileBackground));
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUserDetails()
|
const userPreferences = await window.electron.getUserPreferences();
|
||||||
.then((response) => {
|
const userDetails = await fetchUserDetails().catch(() => null);
|
||||||
if (response) {
|
|
||||||
updateUserDetails(response);
|
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (document.getElementById("external-resources")) return;
|
|
||||||
|
|
||||||
const $script = document.createElement("script");
|
if (userDetails) {
|
||||||
$script.id = "external-resources";
|
updateUserDetails(userDetails);
|
||||||
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
}
|
||||||
document.head.appendChild($script);
|
|
||||||
});
|
setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
|
||||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
|
||||||
|
if (!document.getElementById("external-resources")) {
|
||||||
|
const $script = document.createElement("script");
|
||||||
|
$script.id = "external-resources";
|
||||||
|
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
|
||||||
|
document.head.appendChild($script);
|
||||||
|
}
|
||||||
|
}, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setupExternalResources();
|
||||||
|
}, [setupExternalResources]);
|
||||||
|
|
||||||
const onSignIn = useCallback(() => {
|
const onSignIn = useCallback(() => {
|
||||||
fetchUserDetails().then((response) => {
|
fetchUserDetails().then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
updateUserDetails(response);
|
updateUserDetails(response);
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
showSuccessToast(t("successfully_signed_in"));
|
showSuccessToast(t("successfully_signed_in"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -184,15 +216,27 @@ export function App() {
|
|||||||
updateLibrary();
|
updateLibrary();
|
||||||
}),
|
}),
|
||||||
window.electron.onSignOut(() => clearUserDetails()),
|
window.electron.onSignOut(() => clearUserDetails()),
|
||||||
|
window.electron.onExtractionProgress((shop, objectId, progress) => {
|
||||||
|
dispatch(setExtractionProgress({ shop, objectId, progress }));
|
||||||
|
}),
|
||||||
|
window.electron.onExtractionComplete(() => {
|
||||||
|
dispatch(clearExtraction());
|
||||||
|
updateLibrary();
|
||||||
|
}),
|
||||||
|
window.electron.onArchiveDeletionPrompt((paths) => {
|
||||||
|
setArchivePaths(paths);
|
||||||
|
setShowArchiveDeletionModal(true);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
listeners.forEach((unsubscribe) => unsubscribe());
|
listeners.forEach((unsubscribe) => unsubscribe());
|
||||||
};
|
};
|
||||||
}, [onSignIn, updateLibrary, clearUserDetails]);
|
}, [onSignIn, updateLibrary, clearUserDetails, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||||
|
workwondersRef.current?.notifyUrlChange();
|
||||||
}, [location.pathname, location.search]);
|
}, [location.pathname, location.search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -281,14 +325,11 @@ export function App() {
|
|||||||
feature={hydraCloudFeature}
|
feature={hydraCloudFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{userDetails && (
|
<ArchiveDeletionModal
|
||||||
<UserFriendModal
|
visible={showArchiveDeletionModal}
|
||||||
visible={isFriendsModalVisible}
|
archivePaths={archivePaths}
|
||||||
initialTab={friendRequetsModalTab}
|
onClose={() => setShowArchiveDeletionModal(false)}
|
||||||
onClose={hideFriendsModal}
|
/>
|
||||||
userId={friendModalUserId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useAppSelector,
|
||||||
useDownload,
|
useDownload,
|
||||||
useLibrary,
|
useLibrary,
|
||||||
useToast,
|
useToast,
|
||||||
@@ -26,6 +27,8 @@ export function BottomPanel() {
|
|||||||
|
|
||||||
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
|
||||||
|
|
||||||
|
const extraction = useAppSelector((state) => state.download.extraction);
|
||||||
|
|
||||||
const [version, setVersion] = useState("");
|
const [version, setVersion] = useState("");
|
||||||
const [sessionHash, setSessionHash] = useState<null | string>("");
|
const [sessionHash, setSessionHash] = useState<null | string>("");
|
||||||
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
|
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
|
||||||
@@ -68,6 +71,20 @@ export function BottomPanel() {
|
|||||||
return t("installing_common_redist", { log: commonRedistStatus });
|
return t("installing_common_redist", { log: commonRedistStatus });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (extraction) {
|
||||||
|
const extractingGame = library.find(
|
||||||
|
(game) => game.id === extraction.visibleId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extractingGame) {
|
||||||
|
const extractionPercentage = Math.round(extraction.progress * 100);
|
||||||
|
return t("extracting", {
|
||||||
|
title: extractingGame.title,
|
||||||
|
percentage: `${extractionPercentage}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const game = lastPacket
|
const game = lastPacket
|
||||||
? library.find((game) => game.id === lastPacket?.gameId)
|
? library.find((game) => game.id === lastPacket?.gameId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -109,6 +126,7 @@ export function BottomPanel() {
|
|||||||
eta,
|
eta,
|
||||||
downloadSpeed,
|
downloadSpeed,
|
||||||
commonRedistStatus,
|
commonRedistStatus,
|
||||||
|
extraction,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,10 +140,10 @@ export function BottomPanel() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-featurebase-changelog
|
data-open-workwonders-changelog-mini
|
||||||
className="bottom-panel__version-button"
|
className="bottom-panel__version-button"
|
||||||
>
|
>
|
||||||
<small data-featurebase-changelog>
|
<small>
|
||||||
{sessionHash ? `${sessionHash} -` : ""} v{version} "
|
{sessionHash ? `${sessionHash} -` : ""} v{version} "
|
||||||
{VERSION_CODENAME}"
|
{VERSION_CODENAME}"
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface DropdownMenuProps {
|
|||||||
side?: "top" | "bottom" | "left" | "right";
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
align?: "start" | "center" | "end";
|
align?: "start" | "center" | "end";
|
||||||
alignOffset?: number;
|
alignOffset?: number;
|
||||||
|
collisionPadding?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DropdownMenu({
|
export function DropdownMenu({
|
||||||
@@ -29,6 +30,7 @@ export function DropdownMenu({
|
|||||||
loop = true,
|
loop = true,
|
||||||
align = "center",
|
align = "center",
|
||||||
alignOffset = 0,
|
alignOffset = 0,
|
||||||
|
collisionPadding = 16,
|
||||||
}: Readonly<DropdownMenuProps>) {
|
}: Readonly<DropdownMenuProps>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Root>
|
<DropdownMenuPrimitive.Root>
|
||||||
@@ -43,6 +45,7 @@ export function DropdownMenu({
|
|||||||
loop={loop}
|
loop={loop}
|
||||||
align={align}
|
align={align}
|
||||||
alignOffset={alignOffset}
|
alignOffset={alignOffset}
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
className="dropdown-menu__content"
|
className="dropdown-menu__content"
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.fullscreen-media-modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: globals.$backdrop-z-index;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-media-modal {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
|
||||||
|
&__close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(globals.$spacing-unit * 5);
|
||||||
|
right: calc(globals.$spacing-unit * 4);
|
||||||
|
cursor: pointer;
|
||||||
|
color: globals.$body-color;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid globals.$border-color;
|
||||||
|
padding: globals.$spacing-unit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image-container {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes image-appear {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { XIcon } from "@primer/octicons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import "./fullscreen-media-modal.scss";
|
||||||
|
|
||||||
|
export interface FullscreenMediaModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
src: string | null | undefined;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FullscreenMediaModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
}: FullscreenMediaModalProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { t } = useTranslation("modal");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {};
|
||||||
|
}, [onClose, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const clickedOnImage = containerRef.current.contains(e.target as Node);
|
||||||
|
|
||||||
|
if (!clickedOnImage) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
window.addEventListener("mousedown", onMouseDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousedown", onMouseDown);
|
||||||
|
};
|
||||||
|
}, [onClose, visible]);
|
||||||
|
|
||||||
|
if (!visible || !src) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fullscreen-media-modal__overlay">
|
||||||
|
<dialog className="fullscreen-media-modal" open aria-label={alt}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="fullscreen-media-modal__close-button"
|
||||||
|
aria-label={t("close")}
|
||||||
|
>
|
||||||
|
<XIcon size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="fullscreen-media-modal__image-container"
|
||||||
|
>
|
||||||
|
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
|||||||
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";
|
||||||
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
const pathTitle: Record<string, string> = {
|
const pathTitle: Record<string, string> = {
|
||||||
"/": "home",
|
"/": "home",
|
||||||
@@ -80,6 +82,7 @@ export function Header() {
|
|||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/profile")) return headerTitle;
|
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||||
|
if (location.pathname.startsWith("/notifications")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/library"))
|
if (location.pathname.startsWith("/library"))
|
||||||
return headerTitle || t("library");
|
return headerTitle || t("library");
|
||||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
@@ -161,11 +164,11 @@ export function Header() {
|
|||||||
const handleSelectSuggestion = (suggestion: {
|
const handleSelectSuggestion = (suggestion: {
|
||||||
title: string;
|
title: string;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
shop: string;
|
shop: GameShop;
|
||||||
}) => {
|
}) => {
|
||||||
setIsDropdownVisible(false);
|
setIsDropdownVisible(false);
|
||||||
inputRef.current?.blur();
|
inputRef.current?.blur();
|
||||||
navigate(`/game/${suggestion.shop}/${suggestion.objectId}`);
|
navigate(buildGameDetailsPath(suggestion));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
@@ -221,21 +224,6 @@ export function Header() {
|
|||||||
setActiveIndex(-1);
|
setActiveIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prevPath = sessionStorage.getItem("prevPath");
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
|
|
||||||
if (
|
|
||||||
prevPath?.startsWith("/catalogue") &&
|
|
||||||
!currentPath.startsWith("/catalogue") &&
|
|
||||||
catalogueSearchValue
|
|
||||||
) {
|
|
||||||
dispatch(setFilters({ title: "" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionStorage.setItem("prevPath", currentPath);
|
|
||||||
}, [location.pathname, catalogueSearchValue, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDropdownVisible) return;
|
if (!isDropdownVisible) return;
|
||||||
|
|
||||||
@@ -321,7 +309,8 @@ export function Header() {
|
|||||||
<SearchDropdown
|
<SearchDropdown
|
||||||
visible={
|
visible={
|
||||||
isDropdownVisible &&
|
isDropdownVisible &&
|
||||||
(historyItems.length > 0 ||
|
(searchValue.trim().length > 0 ||
|
||||||
|
historyItems.length > 0 ||
|
||||||
suggestions.length > 0 ||
|
suggestions.length > 0 ||
|
||||||
isLoadingSuggestions)
|
isLoadingSuggestions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ export * from "./game-context-menu/game-context-menu";
|
|||||||
export * from "./game-context-menu/use-game-actions";
|
export * from "./game-context-menu/use-game-actions";
|
||||||
export * from "./star-rating/star-rating";
|
export * from "./star-rating/star-rating";
|
||||||
export * from "./search-dropdown/search-dropdown";
|
export * from "./search-dropdown/search-dropdown";
|
||||||
|
export * from "./fullscreen-media-modal/fullscreen-media-modal";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import cn from "classnames";
|
|||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
description?: string;
|
description?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
@@ -115,7 +115,6 @@ export function Modal({
|
|||||||
"modal--large": large,
|
"modal--large": large,
|
||||||
})}
|
})}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby={title}
|
|
||||||
aria-describedby={description}
|
aria-describedby={description}
|
||||||
ref={modalContentRef}
|
ref={modalContentRef}
|
||||||
data-hydra-dialog
|
data-hydra-dialog
|
||||||
|
|||||||
@@ -19,24 +19,25 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
|
|||||||
return <>{text}</>;
|
return <>{text}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textWords = text.split(/\b/);
|
const matches: { start: number; end: number }[] = [];
|
||||||
const matches: { start: number; end: number; text: string }[] = [];
|
const textLower = text.toLowerCase();
|
||||||
|
|
||||||
let currentIndex = 0;
|
queryWords.forEach((queryWord) => {
|
||||||
textWords.forEach((word) => {
|
const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const wordLower = word.toLowerCase();
|
const regex = new RegExp(
|
||||||
|
`(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`,
|
||||||
|
"gi"
|
||||||
|
);
|
||||||
|
|
||||||
queryWords.forEach((queryWord) => {
|
let match;
|
||||||
if (wordLower === queryWord) {
|
while ((match = regex.exec(textLower)) !== null) {
|
||||||
matches.push({
|
const matchedText = match[0];
|
||||||
start: currentIndex,
|
const leadingSpace = matchedText.startsWith(" ") ? 1 : 0;
|
||||||
end: currentIndex + word.length,
|
const start = match.index + leadingSpace;
|
||||||
text: word,
|
const end = start + queryWord.length;
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIndex += word.length;
|
matches.push({ start, end });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
@@ -46,16 +47,14 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
|
|||||||
matches.sort((a, b) => a.start - b.start);
|
matches.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
const mergedMatches: { start: number; end: number }[] = [];
|
const mergedMatches: { start: number; end: number }[] = [];
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return <>{text}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current = matches[0];
|
let current = matches[0];
|
||||||
|
|
||||||
for (let i = 1; i < matches.length; i++) {
|
for (let i = 1; i < matches.length; i++) {
|
||||||
if (matches[i].start <= current.end) {
|
if (matches[i].start <= current.end) {
|
||||||
current.end = Math.max(current.end, matches[i].end);
|
current = {
|
||||||
|
start: current.start,
|
||||||
|
end: Math.max(current.end, matches[i].end),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
mergedMatches.push(current);
|
mergedMatches.push(current);
|
||||||
current = matches[i];
|
current = matches[i];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
background-color: globals.$dark-background-color;
|
background-color: globals.$dark-background-color;
|
||||||
border: 1px solid globals.$border-color;
|
border: 1px solid globals.$border-color;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-height: 300px;
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px 4px;
|
padding: 8px 12px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section-title {
|
&__section-title {
|
||||||
@@ -35,19 +36,19 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__clear-button {
|
&__clear-text-button {
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 0;
|
||||||
border-radius: 4px;
|
font-size: 11px;
|
||||||
transition: all ease 0.2s;
|
font-weight: bold;
|
||||||
display: flex;
|
text-transform: uppercase;
|
||||||
align-items: center;
|
transition: color ease 0.2s;
|
||||||
justify-content: center;
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +75,8 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 4px;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all ease 0.15s;
|
transition: opacity ease 0.15s;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
ClockIcon,
|
|
||||||
SearchIcon,
|
|
||||||
TrashIcon,
|
|
||||||
XIcon,
|
|
||||||
} from "@primer/octicons-react";
|
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
|
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
|
||||||
@@ -97,23 +92,8 @@ export function SearchDropdown({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [visible, onClose, searchContainerRef]);
|
}, [visible, onClose, searchContainerRef]);
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
|
||||||
(
|
|
||||||
type: "history" | "suggestion",
|
|
||||||
item: SearchHistoryEntry | SearchSuggestion
|
|
||||||
) => {
|
|
||||||
if (type === "history") {
|
|
||||||
onSelectHistory((item as SearchHistoryEntry).query);
|
|
||||||
} else {
|
|
||||||
onSelectSuggestion(item as SearchSuggestion);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onSelectHistory, onSelectSuggestion]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
const totalItems = historyItems.length + suggestions.length;
|
|
||||||
const hasHistory = historyItems.length > 0;
|
const hasHistory = historyItems.length > 0;
|
||||||
const hasSuggestions = suggestions.length > 0;
|
const hasSuggestions = suggestions.length > 0;
|
||||||
|
|
||||||
@@ -144,11 +124,10 @@ export function SearchDropdown({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="search-dropdown__clear-button"
|
className="search-dropdown__clear-text-button"
|
||||||
onClick={onClearHistory}
|
onClick={onClearHistory}
|
||||||
title={t("clear_history")}
|
|
||||||
>
|
>
|
||||||
<TrashIcon size={14} />
|
{t("clear_history")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="search-dropdown__list">
|
<ul className="search-dropdown__list">
|
||||||
@@ -164,7 +143,7 @@ export function SearchDropdown({
|
|||||||
activeIndex === getItemIndex("history", index),
|
activeIndex === getItemIndex("history", index),
|
||||||
})}
|
})}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleItemClick("history", item)}
|
onClick={() => onSelectHistory(item.query)}
|
||||||
>
|
>
|
||||||
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
||||||
<span className="search-dropdown__item-text">
|
<span className="search-dropdown__item-text">
|
||||||
@@ -206,7 +185,7 @@ export function SearchDropdown({
|
|||||||
activeIndex === getItemIndex("suggestion", index),
|
activeIndex === getItemIndex("suggestion", index),
|
||||||
})}
|
})}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleItemClick("suggestion", item)}
|
onClick={() => onSelectSuggestion(item)}
|
||||||
>
|
>
|
||||||
{item.iconUrl ? (
|
{item.iconUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -233,13 +212,6 @@ export function SearchDropdown({
|
|||||||
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
||||||
<div className="search-dropdown__loading">{t("loading")}</div>
|
<div className="search-dropdown__loading">{t("loading")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingSuggestions &&
|
|
||||||
!hasHistory &&
|
|
||||||
!hasSuggestions &&
|
|
||||||
totalItems === 0 && (
|
|
||||||
<div className="search-dropdown__empty">{t("no_results")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cn from "classnames";
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GameContextMenu } from "..";
|
import { GameContextMenu } from "..";
|
||||||
|
import { useAppSelector } from "@renderer/hooks";
|
||||||
|
|
||||||
interface SidebarGameItemProps {
|
interface SidebarGameItemProps {
|
||||||
game: LibraryGame;
|
game: LibraryGame;
|
||||||
@@ -18,6 +19,9 @@ export function SidebarGameItem({
|
|||||||
getGameTitle,
|
getGameTitle,
|
||||||
}: Readonly<SidebarGameItemProps>) {
|
}: Readonly<SidebarGameItemProps>) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const userPreferences = useAppSelector(
|
||||||
|
(state) => state.userPreferences.value
|
||||||
|
);
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
@@ -81,11 +85,12 @@ export function SidebarGameItem({
|
|||||||
{getGameTitle(game)}
|
{getGameTitle(game)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{(game.newDownloadOptionsCount ?? 0) > 0 && (
|
{userPreferences?.enableNewDownloadOptionsBadges !== false &&
|
||||||
<span className="sidebar__game-badge">
|
(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||||
+{game.newDownloadOptionsCount}
|
<span className="sidebar__game-badge">
|
||||||
</span>
|
+{game.newDownloadOptionsCount}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__friends-button {
|
&__notification-button {
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__friends-button-badge {
|
&__notification-button-badge {
|
||||||
background-color: globals.$success-color;
|
background-color: globals.$success-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__game-running-icon {
|
&__game-running-icon {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PeopleIcon } from "@primer/octicons-react";
|
import { BellIcon } from "@primer/octicons-react";
|
||||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
import { Avatar } from "../avatar/avatar";
|
import { Avatar } from "../avatar/avatar";
|
||||||
import { AuthPage } from "@shared";
|
import { AuthPage } from "@shared";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
|
import type { NotificationCountResponse } from "@types";
|
||||||
import "./sidebar-profile.scss";
|
import "./sidebar-profile.scss";
|
||||||
|
|
||||||
export function SidebarProfile() {
|
export function SidebarProfile() {
|
||||||
@@ -14,11 +15,75 @@ export function SidebarProfile() {
|
|||||||
|
|
||||||
const { t } = useTranslation("sidebar");
|
const { t } = useTranslation("sidebar");
|
||||||
|
|
||||||
const { userDetails, friendRequestCount, showFriendsModal } =
|
const { userDetails } = useUserDetails();
|
||||||
useUserDetails();
|
|
||||||
|
|
||||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||||
|
|
||||||
|
const [notificationCount, setNotificationCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchNotificationCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Always fetch local notification count
|
||||||
|
const localCount = await window.electron.getLocalNotificationsCount();
|
||||||
|
|
||||||
|
// Fetch API notification count only if logged in
|
||||||
|
let apiCount = 0;
|
||||||
|
if (userDetails) {
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||||
|
"/profile/notifications/count",
|
||||||
|
{ needsAuth: true }
|
||||||
|
);
|
||||||
|
apiCount = response.count;
|
||||||
|
} catch {
|
||||||
|
// Ignore API errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationCount(localCount + apiCount);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to fetch notification count", error);
|
||||||
|
}
|
||||||
|
}, [userDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotificationCount();
|
||||||
|
|
||||||
|
const interval = setInterval(fetchNotificationCount, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchNotificationCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||||
|
fetchNotificationCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [fetchNotificationCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNotificationsChange = () => {
|
||||||
|
fetchNotificationCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
"notificationsChanged",
|
||||||
|
handleNotificationsChange
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [fetchNotificationCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electron.onSyncNotificationCount(() => {
|
||||||
|
fetchNotificationCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [fetchNotificationCount]);
|
||||||
|
|
||||||
const handleProfileClick = () => {
|
const handleProfileClick = () => {
|
||||||
if (userDetails === null) {
|
if (userDetails === null) {
|
||||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||||
@@ -28,28 +93,24 @@ export function SidebarProfile() {
|
|||||||
navigate(`/profile/${userDetails.id}`);
|
navigate(`/profile/${userDetails.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const friendsButton = useMemo(() => {
|
const notificationsButton = useMemo(() => {
|
||||||
if (!userDetails) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sidebar-profile__friends-button"
|
className="sidebar-profile__notification-button"
|
||||||
onClick={() =>
|
onClick={() => navigate("/notifications")}
|
||||||
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
|
title={t("notifications")}
|
||||||
}
|
|
||||||
title={t("friends")}
|
|
||||||
>
|
>
|
||||||
{friendRequestCount > 0 && (
|
{notificationCount > 0 && (
|
||||||
<small className="sidebar-profile__friends-button-badge">
|
<small className="sidebar-profile__notification-button-badge">
|
||||||
{friendRequestCount > 99 ? "99+" : friendRequestCount}
|
{notificationCount > 99 ? "99+" : notificationCount}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PeopleIcon size={16} />
|
<BellIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}, [userDetails, t, friendRequestCount, showFriendsModal]);
|
}, [t, notificationCount, navigate]);
|
||||||
|
|
||||||
const gameRunningDetails = () => {
|
const gameRunningDetails = () => {
|
||||||
if (!userDetails || !gameRunning) return null;
|
if (!userDetails || !gameRunning) return null;
|
||||||
@@ -98,7 +159,7 @@ export function SidebarProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{friendsButton}
|
{notificationsButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
|
|
||||||
export const VERSION_CODENAME = "Supernova";
|
export const VERSION_CODENAME = "Harbinger";
|
||||||
|
|
||||||
export const DOWNLOADER_NAME = {
|
export const DOWNLOADER_NAME = {
|
||||||
[Downloader.RealDebrid]: "Real-Debrid",
|
[Downloader.RealDebrid]: "Real-Debrid",
|
||||||
[Downloader.Torrent]: "Torrent",
|
[Downloader.Torrent]: "Torrent",
|
||||||
[Downloader.Gofile]: "Gofile",
|
[Downloader.Gofile]: "Gofile",
|
||||||
[Downloader.PixelDrain]: "PixelDrain",
|
[Downloader.PixelDrain]: "PixelDrain",
|
||||||
[Downloader.Qiwi]: "Qiwi",
|
|
||||||
[Downloader.Datanodes]: "Datanodes",
|
[Downloader.Datanodes]: "Datanodes",
|
||||||
[Downloader.Mediafire]: "Mediafire",
|
[Downloader.Mediafire]: "Mediafire",
|
||||||
|
[Downloader.Buzzheavier]: "Buzzheavier",
|
||||||
|
[Downloader.FuckingFast]: "FuckingFast",
|
||||||
[Downloader.TorBox]: "TorBox",
|
[Downloader.TorBox]: "TorBox",
|
||||||
[Downloader.Hydra]: "Nimbus",
|
[Downloader.Hydra]: "Nimbus",
|
||||||
|
[Downloader.VikingFile]: "VikingFile",
|
||||||
|
[Downloader.Rootz]: "Rootz",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||||
|
|||||||
26
src/renderer/src/declaration.d.ts
vendored
26
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,7 @@ import type {
|
|||||||
GameStats,
|
GameStats,
|
||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
GameArtifact,
|
GameArtifact,
|
||||||
LudusaviBackup,
|
LudusaviBackup,
|
||||||
UserAchievement,
|
UserAchievement,
|
||||||
@@ -31,6 +32,7 @@ import type {
|
|||||||
Game,
|
Game,
|
||||||
DiskUsage,
|
DiskUsage,
|
||||||
DownloadSource,
|
DownloadSource,
|
||||||
|
LocalNotification,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
|
||||||
@@ -165,6 +167,10 @@ declare global {
|
|||||||
getLibrary: () => Promise<LibraryGame[]>;
|
getLibrary: () => Promise<LibraryGame[]>;
|
||||||
refreshLibraryAssets: () => Promise<void>;
|
refreshLibraryAssets: () => Promise<void>;
|
||||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||||
|
getGameInstallerActionType: (
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string
|
||||||
|
) => Promise<"install" | "open-folder">;
|
||||||
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
openGame: (
|
openGame: (
|
||||||
@@ -208,6 +214,13 @@ declare global {
|
|||||||
onExtractionComplete: (
|
onExtractionComplete: (
|
||||||
cb: (shop: GameShop, objectId: string) => void
|
cb: (shop: GameShop, objectId: string) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
onExtractionProgress: (
|
||||||
|
cb: (shop: GameShop, objectId: string, progress: number) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
|
onArchiveDeletionPrompt: (
|
||||||
|
cb: (archivePaths: string[]) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
|
deleteArchive: (filePath: string) => Promise<boolean>;
|
||||||
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
|
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
|
||||||
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
|
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
|
|
||||||
@@ -380,10 +393,12 @@ declare global {
|
|||||||
processProfileImage: (
|
processProfileImage: (
|
||||||
path: string
|
path: string
|
||||||
) => Promise<{ imagePath: string; mimeType: string }>;
|
) => Promise<{ imagePath: string; mimeType: string }>;
|
||||||
syncFriendRequests: () => Promise<void>;
|
|
||||||
onSyncFriendRequests: (
|
onSyncFriendRequests: (
|
||||||
cb: (friendRequests: FriendRequestSync) => void
|
cb: (friendRequests: FriendRequestSync) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
onSyncNotificationCount: (
|
||||||
|
cb: (notification: NotificationSync) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
updateFriendRequest: (
|
updateFriendRequest: (
|
||||||
userId: string,
|
userId: string,
|
||||||
action: FriendRequestAction
|
action: FriendRequestAction
|
||||||
@@ -391,6 +406,15 @@ declare global {
|
|||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||||
|
getLocalNotifications: () => Promise<LocalNotification[]>;
|
||||||
|
getLocalNotificationsCount: () => Promise<number>;
|
||||||
|
markLocalNotificationRead: (id: string) => Promise<void>;
|
||||||
|
markAllLocalNotificationsRead: () => Promise<void>;
|
||||||
|
deleteLocalNotification: (id: string) => Promise<void>;
|
||||||
|
clearAllLocalNotifications: () => Promise<void>;
|
||||||
|
onLocalNotificationCreated: (
|
||||||
|
cb: (notification: LocalNotification) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
onAchievementUnlocked: (
|
onAchievementUnlocked: (
|
||||||
cb: (
|
cb: (
|
||||||
position?: AchievementCustomNotificationPosition,
|
position?: AchievementCustomNotificationPosition,
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||||
import type { DownloadProgress } from "@types";
|
import type { DownloadProgress, GameShop } from "@types";
|
||||||
|
|
||||||
|
export interface ExtractionInfo {
|
||||||
|
visibleId: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadState {
|
export interface DownloadState {
|
||||||
lastPacket: DownloadProgress | null;
|
lastPacket: DownloadProgress | null;
|
||||||
gameId: string | null;
|
gameId: string | null;
|
||||||
gamesWithDeletionInProgress: string[];
|
gamesWithDeletionInProgress: string[];
|
||||||
|
extraction: ExtractionInfo | null;
|
||||||
|
peakSpeeds: Record<string, number>;
|
||||||
|
speedHistory: Record<string, number[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: DownloadState = {
|
const initialState: DownloadState = {
|
||||||
lastPacket: null,
|
lastPacket: null,
|
||||||
gameId: null,
|
gameId: null,
|
||||||
gamesWithDeletionInProgress: [],
|
gamesWithDeletionInProgress: [],
|
||||||
|
extraction: null,
|
||||||
|
peakSpeeds: {},
|
||||||
|
speedHistory: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadSlice = createSlice({
|
export const downloadSlice = createSlice({
|
||||||
@@ -20,7 +31,33 @@ export const downloadSlice = createSlice({
|
|||||||
reducers: {
|
reducers: {
|
||||||
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
||||||
state.lastPacket = action.payload;
|
state.lastPacket = action.payload;
|
||||||
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
|
|
||||||
|
// Ensure payload exists and has a valid gameId before accessing
|
||||||
|
const payload = action.payload;
|
||||||
|
if (!state.gameId && payload?.gameId) {
|
||||||
|
state.gameId = payload.gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track peak speed and speed history atomically when packet arrives
|
||||||
|
if (payload?.gameId && payload.downloadSpeed != null) {
|
||||||
|
const { gameId, downloadSpeed } = payload;
|
||||||
|
|
||||||
|
// Update peak speed if this is higher
|
||||||
|
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||||
|
if (downloadSpeed > currentPeak) {
|
||||||
|
state.peakSpeeds[gameId] = downloadSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update speed history for chart
|
||||||
|
if (!state.speedHistory[gameId]) {
|
||||||
|
state.speedHistory[gameId] = [];
|
||||||
|
}
|
||||||
|
state.speedHistory[gameId].push(downloadSpeed);
|
||||||
|
// Keep only last 120 entries
|
||||||
|
if (state.speedHistory[gameId].length > 120) {
|
||||||
|
state.speedHistory[gameId].shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearDownload: (state) => {
|
clearDownload: (state) => {
|
||||||
state.lastPacket = null;
|
state.lastPacket = null;
|
||||||
@@ -38,6 +75,37 @@ export const downloadSlice = createSlice({
|
|||||||
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
|
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
|
||||||
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
|
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
|
||||||
},
|
},
|
||||||
|
setExtractionProgress: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
shop: GameShop;
|
||||||
|
objectId: string;
|
||||||
|
progress: number;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const { shop, objectId, progress } = action.payload;
|
||||||
|
state.extraction = {
|
||||||
|
visibleId: `${shop}:${objectId}`,
|
||||||
|
progress,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clearExtraction: (state) => {
|
||||||
|
state.extraction = null;
|
||||||
|
},
|
||||||
|
updatePeakSpeed: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ gameId: string; speed: number }>
|
||||||
|
) => {
|
||||||
|
const { gameId, speed } = action.payload;
|
||||||
|
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||||
|
if (speed > currentPeak) {
|
||||||
|
state.peakSpeeds[gameId] = speed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearPeakSpeed: (state, action: PayloadAction<string>) => {
|
||||||
|
state.peakSpeeds[action.payload] = 0;
|
||||||
|
state.speedHistory[action.payload] = [];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,4 +114,8 @@ export const {
|
|||||||
clearDownload,
|
clearDownload,
|
||||||
setGameDeleting,
|
setGameDeleting,
|
||||||
removeGameFromDeleting,
|
removeGameFromDeleting,
|
||||||
|
setExtractionProgress,
|
||||||
|
clearExtraction,
|
||||||
|
updatePeakSpeed,
|
||||||
|
clearPeakSpeed,
|
||||||
} = downloadSlice.actions;
|
} = downloadSlice.actions;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
import type { FriendRequest, UserDetails } from "@types";
|
import type { FriendRequest, UserDetails } from "@types";
|
||||||
|
|
||||||
export interface UserDetailsState {
|
export interface UserDetailsState {
|
||||||
@@ -7,9 +6,6 @@ export interface UserDetailsState {
|
|||||||
profileBackground: null | string;
|
profileBackground: null | string;
|
||||||
friendRequests: FriendRequest[];
|
friendRequests: FriendRequest[];
|
||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
isFriendsModalVisible: boolean;
|
|
||||||
friendRequetsModalTab: UserFriendModalTab | null;
|
|
||||||
friendModalUserId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserDetailsState = {
|
const initialState: UserDetailsState = {
|
||||||
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
|
|||||||
profileBackground: null,
|
profileBackground: null,
|
||||||
friendRequests: [],
|
friendRequests: [],
|
||||||
friendRequestCount: 0,
|
friendRequestCount: 0,
|
||||||
isFriendsModalVisible: false,
|
|
||||||
friendRequetsModalTab: null,
|
|
||||||
friendModalUserId: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userDetailsSlice = createSlice({
|
export const userDetailsSlice = createSlice({
|
||||||
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
|
|||||||
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
||||||
state.friendRequestCount = action.payload;
|
state.friendRequestCount = action.payload;
|
||||||
},
|
},
|
||||||
setFriendsModalVisible: (
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
|
|
||||||
) => {
|
|
||||||
state.isFriendsModalVisible = true;
|
|
||||||
state.friendRequetsModalTab = action.payload.initialTab;
|
|
||||||
state.friendModalUserId = action.payload.userId;
|
|
||||||
},
|
|
||||||
setFriendsModalHidden: (state) => {
|
|
||||||
state.isFriendsModalVisible = false;
|
|
||||||
state.friendRequetsModalTab = null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +39,4 @@ export const {
|
|||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
setFriendRequests,
|
setFriendRequests,
|
||||||
setFriendRequestCount,
|
setFriendRequestCount,
|
||||||
setFriendsModalVisible,
|
|
||||||
setFriendsModalHidden,
|
|
||||||
} = userDetailsSlice.actions;
|
} = userDetailsSlice.actions;
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from "./use-download-options-listener";
|
|||||||
export * from "./use-game-card";
|
export * from "./use-game-card";
|
||||||
export * from "./use-search-history";
|
export * from "./use-search-history";
|
||||||
export * from "./use-search-suggestions";
|
export * from "./use-search-suggestions";
|
||||||
|
export * from "./use-hls-video";
|
||||||
|
|||||||
102
src/renderer/src/hooks/use-hls-video.ts
Normal file
102
src/renderer/src/hooks/use-hls-video.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Hls from "hls.js";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
|
|
||||||
|
interface UseHlsVideoOptions {
|
||||||
|
videoSrc: string | undefined;
|
||||||
|
videoType: string | undefined;
|
||||||
|
autoplay?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
loop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHlsVideo(
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement>,
|
||||||
|
{ videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions
|
||||||
|
) {
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !videoSrc) return;
|
||||||
|
|
||||||
|
const isHls = videoType === "application/x-mpegURL";
|
||||||
|
|
||||||
|
if (!isHls) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
hlsRef.current = hls;
|
||||||
|
|
||||||
|
hls.loadSource(videoSrc);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
logger.warn("Failed to autoplay HLS video:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
logger.error("HLS network error, trying to recover");
|
||||||
|
hls.startLoad();
|
||||||
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
logger.error("HLS media error, trying to recover");
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.error("HLS fatal error, destroying instance");
|
||||||
|
hls.destroy();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hls.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
};
|
||||||
|
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
video.src = videoSrc;
|
||||||
|
video.load();
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
logger.warn("Failed to autoplay HLS video:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.src = "";
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.warn("HLS playback is not supported in this browser");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [videoRef, videoSrc, videoType, autoplay, muted, loop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (muted !== undefined) {
|
||||||
|
video.muted = muted;
|
||||||
|
}
|
||||||
|
if (loop !== undefined) {
|
||||||
|
video.loop = loop;
|
||||||
|
}
|
||||||
|
}, [videoRef, muted, loop]);
|
||||||
|
|
||||||
|
return hlsRef.current;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||||
|
|
||||||
export interface SearchHistoryEntry {
|
export interface SearchHistoryEntry {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -6,22 +7,32 @@ export interface SearchHistoryEntry {
|
|||||||
context: "library" | "catalogue";
|
context: "library" | "catalogue";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "search-history";
|
const LEVELDB_KEY = "searchHistory";
|
||||||
const MAX_HISTORY_ENTRIES = 15;
|
const MAX_HISTORY_ENTRIES = 15;
|
||||||
|
|
||||||
export function useSearchHistory() {
|
export function useSearchHistory() {
|
||||||
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
|
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
|
||||||
|
const isInitialized = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const loadHistory = async () => {
|
||||||
if (stored) {
|
if (isInitialized.current) return;
|
||||||
|
isInitialized.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(stored) as SearchHistoryEntry[];
|
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
|
||||||
setHistory(parsed);
|
| SearchHistoryEntry[]
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setHistory(data);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
setHistory([]);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
loadHistory();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addToHistory = useCallback(
|
const addToHistory = useCallback(
|
||||||
@@ -39,7 +50,7 @@ export function useSearchHistory() {
|
|||||||
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
|
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
|
||||||
);
|
);
|
||||||
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
|
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
levelDBService.put(LEVELDB_KEY, updated, null, "json");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -49,14 +60,14 @@ export function useSearchHistory() {
|
|||||||
const removeFromHistory = useCallback((query: string) => {
|
const removeFromHistory = useCallback((query: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((entry) => entry.query !== query);
|
const updated = prev.filter((entry) => entry.query !== query);
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
levelDBService.put(LEVELDB_KEY, updated, null, "json");
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearHistory = useCallback(() => {
|
const clearHistory = useCallback(() => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
levelDBService.del(LEVELDB_KEY, null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getRecentHistory = useCallback(
|
const getRecentHistory = useCallback(
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import { useAppSelector } from "./redux";
|
import { useAppSelector } from "./redux";
|
||||||
import { debounce } from "lodash-es";
|
import { debounce } from "lodash-es";
|
||||||
import { logger } from "@renderer/logger";
|
import { logger } from "@renderer/logger";
|
||||||
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
export interface SearchSuggestion {
|
export interface SearchSuggestion {
|
||||||
title: string;
|
title: string;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
shop: string;
|
shop: GameShop;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
source: "library" | "catalogue";
|
source: "library" | "catalogue";
|
||||||
}
|
}
|
||||||
@@ -20,6 +21,7 @@ export function useSearchSuggestions(
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const library = useAppSelector((state) => state.library.value);
|
const library = useAppSelector((state) => state.library.value);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
|
||||||
|
|
||||||
const getLibrarySuggestions = useCallback(
|
const getLibrarySuggestions = useCallback(
|
||||||
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
|
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
|
||||||
@@ -68,6 +70,15 @@ export function useSearchSuggestions(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
|
||||||
|
const cachedResults = cacheRef.current.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedResults) {
|
||||||
|
setSuggestions(cachedResults);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
@@ -79,7 +90,7 @@ export function useSearchSuggestions(
|
|||||||
{
|
{
|
||||||
title: string;
|
title: string;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
shop: string;
|
shop: GameShop;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
}[]
|
}[]
|
||||||
>("/catalogue/search/suggestions", {
|
>("/catalogue/search/suggestions", {
|
||||||
@@ -99,6 +110,7 @@ export function useSearchSuggestions(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
cacheRef.current.set(cacheKey, catalogueSuggestions);
|
||||||
setSuggestions(catalogueSuggestions);
|
setSuggestions(catalogueSuggestions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
setUserDetails,
|
setUserDetails,
|
||||||
setFriendRequests,
|
setFriendRequests,
|
||||||
setFriendsModalVisible,
|
|
||||||
setFriendsModalHidden,
|
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import type {
|
import type {
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
@@ -13,20 +11,12 @@ import type {
|
|||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
|
|
||||||
export function useUserDetails() {
|
export function useUserDetails() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const {
|
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
|
||||||
userDetails,
|
useAppSelector((state) => state.userDetails);
|
||||||
profileBackground,
|
|
||||||
friendRequests,
|
|
||||||
friendRequestCount,
|
|
||||||
isFriendsModalVisible,
|
|
||||||
friendModalUserId,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
} = useAppSelector((state) => state.userDetails);
|
|
||||||
|
|
||||||
const clearUserDetails = useCallback(async () => {
|
const clearUserDetails = useCallback(async () => {
|
||||||
dispatch(setUserDetails(null));
|
dispatch(setUserDetails(null));
|
||||||
@@ -69,6 +59,7 @@ export function useUserDetails() {
|
|||||||
username: userDetails?.username || "",
|
username: userDetails?.username || "",
|
||||||
subscription: userDetails?.subscription || null,
|
subscription: userDetails?.subscription || null,
|
||||||
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
||||||
|
workwondersJwt: userDetails?.workwondersJwt || "",
|
||||||
karma: userDetails?.karma || 0,
|
karma: userDetails?.karma || 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -85,24 +76,11 @@ export function useUserDetails() {
|
|||||||
return window.electron.hydraApi
|
return window.electron.hydraApi
|
||||||
.get<FriendRequest[]>("/profile/friend-requests")
|
.get<FriendRequest[]>("/profile/friend-requests")
|
||||||
.then((friendRequests) => {
|
.then((friendRequests) => {
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
dispatch(setFriendRequests(friendRequests));
|
dispatch(setFriendRequests(friendRequests));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const showFriendsModal = useCallback(
|
|
||||||
(initialTab: UserFriendModalTab, userId: string) => {
|
|
||||||
dispatch(setFriendsModalVisible({ initialTab, userId }));
|
|
||||||
fetchFriendRequests();
|
|
||||||
},
|
|
||||||
[dispatch, fetchFriendRequests]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideFriendsModal = useCallback(() => {
|
|
||||||
dispatch(setFriendsModalHidden());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const sendFriendRequest = useCallback(
|
const sendFriendRequest = useCallback(
|
||||||
async (userId: string) => {
|
async (userId: string) => {
|
||||||
return window.electron.hydraApi
|
return window.electron.hydraApi
|
||||||
@@ -134,7 +112,7 @@ export function useUserDetails() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const undoFriendship = (userId: string) =>
|
const undoFriendship = (userId: string) =>
|
||||||
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
|
window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
|
||||||
|
|
||||||
const blockUser = (userId: string) =>
|
const blockUser = (userId: string) =>
|
||||||
window.electron.hydraApi.post(`/users/${userId}/block`);
|
window.electron.hydraApi.post(`/users/${userId}/block`);
|
||||||
@@ -152,12 +130,7 @@ export function useUserDetails() {
|
|||||||
profileBackground,
|
profileBackground,
|
||||||
friendRequests,
|
friendRequests,
|
||||||
friendRequestCount,
|
friendRequestCount,
|
||||||
friendRequetsModalTab,
|
|
||||||
isFriendsModalVisible,
|
|
||||||
friendModalUserId,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
showFriendsModal,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
signOut,
|
signOut,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -31,10 +32,23 @@ import Profile from "./pages/profile/profile";
|
|||||||
import Achievements from "./pages/achievements/achievements";
|
import Achievements from "./pages/achievements/achievements";
|
||||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||||
import Library from "./pages/library/library";
|
import Library from "./pages/library/library";
|
||||||
|
import Notifications from "./pages/notifications/notifications";
|
||||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||||
|
|
||||||
console.log = logger.log;
|
console.log = logger.log;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -76,6 +90,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/profile/:userId" element={<Profile />} />
|
<Route path="/profile/:userId" element={<Profile />} />
|
||||||
<Route path="/achievements" element={<Achievements />} />
|
<Route path="/achievements" element={<Achievements />} />
|
||||||
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
<Route path="/theme-editor" element={<ThemeEditor />} />
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConfirmationModal } from "@renderer/components";
|
||||||
|
|
||||||
|
interface ArchiveDeletionModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
archivePaths: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchiveDeletionModal({
|
||||||
|
visible,
|
||||||
|
archivePaths,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<ArchiveDeletionModalProps>) {
|
||||||
|
const { t } = useTranslation("downloads");
|
||||||
|
|
||||||
|
const fullFileName =
|
||||||
|
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
|
||||||
|
|
||||||
|
const maxLength = 40;
|
||||||
|
const fileName =
|
||||||
|
fullFileName.length > maxLength
|
||||||
|
? `${fullFileName.slice(0, maxLength)}…`
|
||||||
|
: fullFileName;
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
for (const archivePath of archivePaths) {
|
||||||
|
await window.electron.deleteArchive(archivePath);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
visible={visible}
|
||||||
|
title={t("delete_archive_title", { fileName })}
|
||||||
|
descriptionText={t("delete_archive_description")}
|
||||||
|
confirmButtonLabel={t("yes")}
|
||||||
|
cancelButtonLabel={t("no")}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,158 +4,550 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: calc(globals.$spacing-unit * 2);
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
margin-inline: calc(globals.$spacing-unit * 3);
|
||||||
|
padding-block: calc(globals.$spacing-unit * 3);
|
||||||
|
|
||||||
&__details-with-article {
|
&--queued {
|
||||||
display: flex;
|
padding-bottom: 0;
|
||||||
align-items: center;
|
}
|
||||||
gap: calc(globals.$spacing-unit / 2);
|
|
||||||
align-self: flex-start;
|
&--completed {
|
||||||
cursor: pointer;
|
padding-top: calc(globals.$spacing-unit * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: calc(globals.$spacing-unit);
|
||||||
gap: calc(globals.$spacing-unit * 2);
|
|
||||||
|
|
||||||
&-divider {
|
&-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: globals.$border-color;
|
|
||||||
height: 1px;
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-count {
|
&-count {
|
||||||
font-weight: 400;
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&--hero {
|
||||||
&__title-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: globals.$spacing-unit;
|
|
||||||
gap: globals.$spacing-unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
color: globals.$body-color;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 16px;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__downloads {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: calc(globals.$spacing-unit * 2);
|
position: relative;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
flex-direction: column;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-top: globals.$spacing-unit;
|
padding-bottom: globals.$spacing-unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__hero-background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: globals.$background-color;
|
height: 120%;
|
||||||
display: flex;
|
z-index: 0;
|
||||||
border-radius: 8px;
|
|
||||||
border: solid 1px globals.$border-color;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0px 0px 5px 0px #000000;
|
|
||||||
transition: all ease 0.2s;
|
|
||||||
height: 140px;
|
|
||||||
min-height: 140px;
|
|
||||||
max-height: 140px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&--hydra {
|
img {
|
||||||
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: 50% 20%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&__cover {
|
// PLEASE FIX THE COLORS
|
||||||
width: 280px;
|
&__hero-overlay {
|
||||||
min-width: 280px;
|
position: absolute;
|
||||||
height: auto;
|
top: 0;
|
||||||
border-right: solid 1px globals.$border-color;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.3) 0%,
|
||||||
|
rgb(5, 5, 5) 70%,
|
||||||
|
rgb(26, 26, 26) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
padding: calc(globals.$spacing-unit * 4);
|
||||||
&-content {
|
padding-bottom: 0;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: globals.$spacing-unit;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-backdrop {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.8) 5%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__right-content {
|
|
||||||
display: flex;
|
|
||||||
padding: calc(globals.$spacing-unit * 2);
|
|
||||||
flex: 1;
|
|
||||||
gap: globals.$spacing-unit;
|
|
||||||
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__details {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
justify-content: center;
|
|
||||||
gap: calc(globals.$spacing-unit / 2);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__hero-logo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: globals.$spacing-unit;
|
|
||||||
|
&-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: scale 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
scale: 1.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 60px;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 700px) {
|
||||||
|
max-width: 220px;
|
||||||
|
max-height: 75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 900px) {
|
||||||
|
max-width: 280px;
|
||||||
|
max-height: 95px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 1200px) {
|
||||||
|
max-width: 340px;
|
||||||
|
max-height: 115px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 1500px) {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 130px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 700px) {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 900px) {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 1200px) {
|
||||||
|
font-size: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 1500px) {
|
||||||
|
font-size: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__menu-button {
|
&__hero-action-row {
|
||||||
position: absolute;
|
display: flex;
|
||||||
top: 12px;
|
justify-content: space-between;
|
||||||
right: 12px;
|
align-items: flex-start;
|
||||||
border-radius: 50%;
|
gap: calc(globals.$spacing-unit * 3);
|
||||||
border: none;
|
margin-top: calc(globals.$spacing-unit * 4);
|
||||||
padding: 8px;
|
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||||
min-height: unset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__hydra-gradient {
|
&__hero-buttons {
|
||||||
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%);
|
display: flex;
|
||||||
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15);
|
gap: calc(globals.$spacing-unit);
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__glass-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-info-row {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
|
||||||
|
&--bar {
|
||||||
|
margin-top: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-status {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-percentage {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
align-self: flex-end;
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-size {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-status {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
font-size: 13px;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: calc(globals.$spacing-unit * 4);
|
||||||
|
padding: calc(globals.$spacing-unit * 2);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(26, 26, 26, 0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
margin-top: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
min-width: 200px;
|
||||||
|
padding-right: calc(globals.$spacing-unit * 2);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__speed-chart {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__speed-chart-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: absolute;
|
height: 80px;
|
||||||
bottom: 0;
|
image-rendering: crisp-edges;
|
||||||
height: 2px;
|
}
|
||||||
z-index: 1;
|
|
||||||
|
&__stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-label {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-value {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-thumbnail {
|
||||||
|
width: 200px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid globals.$border-color;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit / 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-title-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: fit-content;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
font-size: 13px;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-size {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-extracting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
font-weight: 500;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-seeding {
|
||||||
|
color: #4ade80;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__simple-menu-btn {
|
||||||
|
padding: calc(globals.$spacing-unit);
|
||||||
|
min-height: unset;
|
||||||
|
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 {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: calc(globals.$spacing-unit / 2);
|
||||||
|
|
||||||
|
&--small {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&--extraction {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user