mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
200 Commits
feat/displ
...
590e09a8c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590e09a8c3 | ||
|
|
c1d7ea27f3 | ||
|
|
15dbd3b2ad | ||
|
|
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 | ||
|
|
8e3a932aa4 | ||
|
|
1fc87f93b7 | ||
|
|
f28c867479 | ||
|
|
928acc2765 | ||
|
|
140718764d | ||
|
|
f41128c4c8 | ||
|
|
e176e624be | ||
|
|
59b3fb5317 | ||
|
|
d205f2b391 | ||
|
|
82ab889dad | ||
|
|
4e92e794be | ||
|
|
de0dbcac35 | ||
|
|
c3880ce181 | ||
|
|
5e86ad4d7e | ||
|
|
e2fb59ed8d | ||
|
|
07d5a5b3f3 | ||
|
|
a1117c8269 | ||
|
|
dc04cff378 | ||
|
|
6df34e7f3c | ||
|
|
2773fa7b3c | ||
|
|
093a9f251e | ||
|
|
9979e92d8f | ||
|
|
8cd613e3b6 | ||
|
|
28bf7b8764 | ||
|
|
2adc132c33 | ||
|
|
c4852b89f1 | ||
|
|
5bffaf17fa | ||
|
|
cc38be4383 | ||
|
|
0b70a28c08 | ||
|
|
3ff50a9932 | ||
|
|
be3ce6e2db | ||
|
|
83fbf20383 | ||
|
|
c600a4a46f | ||
|
|
d14951f25c | ||
|
|
d6b38771a8 | ||
|
|
8400edd000 | ||
|
|
6e3c5cac7e | ||
|
|
4f2d6f3302 | ||
|
|
72c3219fc0 | ||
|
|
048c56d670 | ||
|
|
43505a281f | ||
|
|
c380e5f5a0 | ||
|
|
5c32e61569 | ||
|
|
f594cd298a | ||
|
|
20c0d3174b | ||
|
|
bcd6db24c9 | ||
|
|
c2216bbf95 | ||
|
|
f84917a00b | ||
|
|
94ebf94abc | ||
|
|
cd3fa10bf7 | ||
|
|
a57cc83076 | ||
|
|
c75a6ad439 | ||
|
|
05d68fa23b | ||
|
|
527a65e9bc | ||
|
|
fe6bb5763d | ||
|
|
002dff098c | ||
|
|
436d1b74be | ||
|
|
b89de065fe | ||
|
|
7fcdab07cb |
20
.cursorrules
20
.cursorrules
@@ -28,6 +28,26 @@
|
|||||||
- 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
|
||||||
|
|
||||||
|
- **Always use `T[]` syntax instead of `Array<T>`** for array types
|
||||||
|
- Prefer: `string[]`, `number[]`, `MyType[]`
|
||||||
|
- Avoid: `Array<string>`, `Array<number>`, `Array<MyType>`
|
||||||
|
- This applies to all type annotations, type assertions, and generic type parameters
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
- Keep comments concise and purposeful; avoid verbose explanations.
|
- Keep comments concise and purposeful; avoid verbose explanations.
|
||||||
|
|||||||
65
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
65
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,65 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Create a report to help us improve. Write in English.
|
|
||||||
title: "[BUG] Write a title for your bug"
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thank you for creating a bug report to help us improve!
|
|
||||||
- type: textarea
|
|
||||||
id: bug-description
|
|
||||||
attributes:
|
|
||||||
label: Describe the bug
|
|
||||||
description: A clear and concise description of what the bug is.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: bug-reproduce
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
description: Steps to reproduce the behavior. For example, "1. Go to '...', 2. Click on '...', 3. See error"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: expected-behavior
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: A clear and concise description of what you expected to happen.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: additional-info
|
|
||||||
attributes:
|
|
||||||
label: Additional information and data
|
|
||||||
description: |
|
|
||||||
Add screenshots and upload your all logs file here.
|
|
||||||
Logs location on Windows: "%appdata%/hydralauncher/logs"
|
|
||||||
Logs location on Linux: "~/.config/hydralauncher/logs"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: OS
|
|
||||||
attributes:
|
|
||||||
label: Operating System
|
|
||||||
description: Which operating system are you using (e.g., Windows 11/Linux Distro/Steam Deck)?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: hydra-version
|
|
||||||
attributes:
|
|
||||||
label: Hydra Version
|
|
||||||
description: Please provide the version of Hydra you are using.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: terms
|
|
||||||
attributes:
|
|
||||||
label: Before opening this Issue
|
|
||||||
options:
|
|
||||||
- label: I have searched the issues of this repository and believe that this is not a duplicate.
|
|
||||||
required: true
|
|
||||||
- label: I am aware that Hydra team does not offer any support or help regarding the downloaded games.
|
|
||||||
required: true
|
|
||||||
- label: I have read the [Frequently Asked Questions (FAQ)](https://github.com/hydralauncher/hydra/wiki/FAQ).
|
|
||||||
required: true
|
|
||||||
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,37 +0,0 @@
|
|||||||
name: Feature Request
|
|
||||||
description: Request a new feature.
|
|
||||||
title: "[REQUEST] "
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thank you for taking the time to suggest a new feature!
|
|
||||||
- type: textarea
|
|
||||||
id: problem-related
|
|
||||||
attributes:
|
|
||||||
label: Is your feature request related to a problem? Please describe.
|
|
||||||
description: A clear and concise description of what the problem is.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: Describe the solution you'd like
|
|
||||||
description: A clear and concise description of what you want to happen.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Describe alternatives you've considered
|
|
||||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context or screenshots about the feature request here.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
4
.github/pull-request-template.md
vendored
4
.github/pull-request-template.md
vendored
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
**When submitting this pull request, I confirm the following (please check the boxes):**
|
**When submitting this pull request, I confirm the following (please check the boxes):**
|
||||||
|
|
||||||
- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute).
|
- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html).
|
||||||
- [ ] I have checked that there are no duplicate pull requests related to this request.
|
- [ ] I have checked that there are no duplicate pull requests related to this request.
|
||||||
- [ ] I have considered, and confirm that this submission is valuable to others.
|
- [ ] I have considered, and confirm that this submission is valuable to others.
|
||||||
- [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers.
|
- [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers.
|
||||||
|
|
||||||
**Fill in the PR content:**
|
**Fill in the PR content:**
|
||||||
|
|
||||||
-
|
|
||||||
|
|||||||
2
.github/workflows/update-aur.yml
vendored
2
.github/workflows/update-aur.yml
vendored
@@ -137,7 +137,7 @@ jobs:
|
|||||||
if git diff --staged --quiet; then
|
if git diff --staged --quiet; then
|
||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
else
|
else
|
||||||
COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
|
COMMIT_MSG="${{ steps.get-version.outputs.version }}"
|
||||||
|
|
||||||
git commit -m "$COMMIT_MSG"
|
git commit -m "$COMMIT_MSG"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[<img src="./resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
|
[<img src="https://raw.githubusercontent.com/hydralauncher/hydra/refs/heads/main/resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
|
||||||
|
|
||||||
<h1 align="center">Hydra Launcher</h1>
|
<h1 align="center">Hydra Launcher</h1>
|
||||||
|
|
||||||
@@ -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.7.6",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
@@ -63,12 +63,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,7 +86,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
2
proto
2
proto
Submodule proto updated: 7a23620f93...6f11c99c57
@@ -153,8 +153,11 @@ def profile_image():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
image_path = data.get('image_path')
|
image_path = data.get('image_path')
|
||||||
|
|
||||||
|
# use webp as default value for target_extension
|
||||||
|
target_extension = data.get('target_extension') or 'webp'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path)
|
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path, target_extension)
|
||||||
return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
|
return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os, uuid, tempfile
|
|||||||
class ProfileImageProcessor:
|
class ProfileImageProcessor:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_parsed_image_data(image_path):
|
def get_parsed_image_data(image_path, target_extension):
|
||||||
Image.MAX_IMAGE_PIXELS = 933120000
|
Image.MAX_IMAGE_PIXELS = 933120000
|
||||||
|
|
||||||
image = Image.open(image_path)
|
image = Image.open(image_path)
|
||||||
@@ -16,7 +16,7 @@ class ProfileImageProcessor:
|
|||||||
return image_path, mime_type
|
return image_path, mime_type
|
||||||
else:
|
else:
|
||||||
new_uuid = str(uuid.uuid4())
|
new_uuid = str(uuid.uuid4())
|
||||||
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp"
|
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension
|
||||||
image.save(new_image_path)
|
image.save(new_image_path)
|
||||||
|
|
||||||
new_image = Image.open(new_image_path)
|
new_image = Image.open(new_image_path)
|
||||||
@@ -26,5 +26,5 @@ class ProfileImageProcessor:
|
|||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def process_image(image_path):
|
def process_image(image_path, target_extension):
|
||||||
return ProfileImageProcessor.get_parsed_image_data(image_path)
|
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -94,6 +95,12 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"search": "Search games",
|
"search": "Search games",
|
||||||
"search_library": "Search library",
|
"search_library": "Search library",
|
||||||
|
"recent_searches": "Recent Searches",
|
||||||
|
"suggestions": "Suggestions",
|
||||||
|
"clear_history": "Clear",
|
||||||
|
"remove_from_history": "Remove from history",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"no_results": "No results",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"catalogue": "Catalogue",
|
"catalogue": "Catalogue",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
@@ -109,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"
|
||||||
@@ -196,6 +204,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",
|
||||||
@@ -408,7 +417,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",
|
||||||
@@ -544,6 +559,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",
|
||||||
@@ -648,6 +664,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",
|
||||||
@@ -658,12 +675,16 @@
|
|||||||
"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",
|
||||||
|
"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",
|
||||||
@@ -687,6 +708,7 @@
|
|||||||
"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",
|
||||||
"upload_banner": "Upload banner",
|
"upload_banner": "Upload banner",
|
||||||
"uploading_banner": "Uploading banner…",
|
"uploading_banner": "Uploading banner…",
|
||||||
"background_image_updated": "Background image updated",
|
"background_image_updated": "Background image updated",
|
||||||
@@ -709,7 +731,10 @@
|
|||||||
"karma_description": "Earned from positive likes on reviews",
|
"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",
|
||||||
|
"view_my_wrapped_button": "View My Wrapped 2025",
|
||||||
|
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
@@ -760,5 +785,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,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",
|
||||||
@@ -450,6 +458,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",
|
||||||
@@ -555,6 +564,19 @@
|
|||||||
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
|
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
|
||||||
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
|
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
|
||||||
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
|
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
|
||||||
|
"change_achievement_sound": "Cambiar sonido de logro",
|
||||||
|
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
|
||||||
|
"download_source_failed": "Error",
|
||||||
|
"download_source_matched": "Actualizado",
|
||||||
|
"download_source_matching": "Actualizando",
|
||||||
|
"download_source_no_information": "Sin información disponible",
|
||||||
|
"download_source_pending_matching": "Actualizando pronto",
|
||||||
|
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
|
||||||
|
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
|
||||||
|
"hydra_cloud": "Hydra Cloud",
|
||||||
|
"preview_sound": "Vista previa de sonido",
|
||||||
|
"remove_achievement_sound": "Eliminar sonido de logros",
|
||||||
|
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
|
||||||
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
|
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
|||||||
@@ -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é",
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
"no_results": "Nincs találat",
|
"no_results": "Nincs találat",
|
||||||
"start_typing": "Kereséshez gépelj...",
|
"start_typing": "Kereséshez gépelj...",
|
||||||
"hot": "Most felkapott",
|
"hot": "Most felkapott",
|
||||||
"weekly": "📅 A hét felkapottjai",
|
"weekly": "📅 Heti kiemeltek",
|
||||||
"achievements": "🏆 Achievement támogatott"
|
"achievements": "🏆 Achievement támogatott"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"catalogue": "Katalógus",
|
"catalogue": "Katalógus",
|
||||||
|
"library": "Könyvtár",
|
||||||
"downloads": "Letöltések",
|
"downloads": "Letöltések",
|
||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
"my_library": "Könyvtáram",
|
"my_library": "Könyvtáram",
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
"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",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"update_decky_plugin": "Decky Plugin Frissítése",
|
"update_decky_plugin": "Decky Plugin Frissítése",
|
||||||
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
||||||
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint",
|
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint",
|
||||||
"install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
|
"install_decky_plugin_message": "Ez letölti és telepíti a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
|
||||||
"update_decky_plugin_title": "Hydra Decky Plugin Frissítése",
|
"update_decky_plugin_title": "Hydra Decky Plugin Frissítése",
|
||||||
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
|
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
|
||||||
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
|
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
|
||||||
@@ -92,8 +93,16 @@
|
|||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Keresés",
|
"search": "Keresés",
|
||||||
|
"search_library": "Könyvtár böngészése",
|
||||||
|
"recent_searches": "Korábbi Keresések",
|
||||||
|
"suggestions": "Találatok",
|
||||||
|
"clear_history": "Törlés",
|
||||||
|
"remove_from_history": "Törlés az előzményekből",
|
||||||
|
"loading": "Töltés...",
|
||||||
|
"no_results": "Nincs találat",
|
||||||
"home": "Főoldal",
|
"home": "Főoldal",
|
||||||
"catalogue": "Katalógus",
|
"catalogue": "Katalógus",
|
||||||
|
"library": "Könyvtár",
|
||||||
"downloads": "Letöltések",
|
"downloads": "Letöltések",
|
||||||
"search_results": "Keresési találatok",
|
"search_results": "Keresési találatok",
|
||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
@@ -106,6 +115,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"
|
||||||
@@ -117,7 +127,7 @@
|
|||||||
"tags": "Címkék",
|
"tags": "Címkék",
|
||||||
"publishers": "Kiadók",
|
"publishers": "Kiadók",
|
||||||
"download_sources": "Letöltési források",
|
"download_sources": "Letöltési források",
|
||||||
"result_count": "{{resultCount}} találatok",
|
"result_count": "{{resultCount}} találat",
|
||||||
"filter_count": "{{filterCount}} elérhető",
|
"filter_count": "{{filterCount}} elérhető",
|
||||||
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
|
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
|
||||||
},
|
},
|
||||||
@@ -162,15 +172,15 @@
|
|||||||
"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",
|
||||||
"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öltis hely",
|
"download_path": "Letöltési hely",
|
||||||
"previous_screenshot": "Előző screenshot",
|
"previous_screenshot": "Előző screenshot",
|
||||||
"next_screenshot": "Következő screenshot",
|
"next_screenshot": "Következő screenshot",
|
||||||
"screenshot": "Screenshot {{number}}",
|
"screenshot": "Screenshot {{number}}",
|
||||||
"open_screenshot": "Screenshot megnyitása {{number}}",
|
"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ési mód",
|
||||||
"select_executable": "Tallózás",
|
"select_executable": "Tallózás",
|
||||||
@@ -193,7 +203,9 @@
|
|||||||
"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",
|
||||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||||
"create_shortcut_success": "A parancsikon létrehozása sikeres",
|
"create_shortcut_success": "A parancsikon létrehozása sikeres",
|
||||||
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
|
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
|
||||||
@@ -223,6 +235,7 @@
|
|||||||
"show_more": "Mutass többet",
|
"show_more": "Mutass többet",
|
||||||
"show_less": "Mutass kevesebbet",
|
"show_less": "Mutass kevesebbet",
|
||||||
"reviews": "Vélemények",
|
"reviews": "Vélemények",
|
||||||
|
"review_played_for": "Játszva",
|
||||||
"leave_a_review": "Hagyd itt a véleményed",
|
"leave_a_review": "Hagyd itt a véleményed",
|
||||||
"write_review_placeholder": "Oszd meg gondolatod a játékról...",
|
"write_review_placeholder": "Oszd meg gondolatod a játékról...",
|
||||||
"sort_newest": "Legújabb",
|
"sort_newest": "Legújabb",
|
||||||
@@ -361,7 +374,10 @@
|
|||||||
"show_original": "Eredeti megjelenítése",
|
"show_original": "Eredeti megjelenítése",
|
||||||
"show_translation": "Fordítás megjelenítése",
|
"show_translation": "Fordítás megjelenítése",
|
||||||
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
|
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
|
||||||
"hide_original": "Eredeti elrejtése"
|
"hide_original": "Eredeti elrejtése",
|
||||||
|
"review_from_blocked_user": "Letiltott felhasználó véleménye",
|
||||||
|
"show": "Megjelenítés",
|
||||||
|
"hide": "Elrejtés"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Hydra Aktiválása",
|
"title": "Hydra Aktiválása",
|
||||||
@@ -389,7 +405,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..",
|
||||||
@@ -400,7 +416,11 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"downloads_path": "Letöltési útvonalak",
|
"downloads_path": "Letöltési útvonalak",
|
||||||
@@ -488,11 +508,11 @@
|
|||||||
"no_email_account": "Még nincs beállított emailed",
|
"no_email_account": "Még nincs beállított emailed",
|
||||||
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
|
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
|
||||||
"renew_subscription": "Hydra Cloud Megújítása",
|
"renew_subscription": "Hydra Cloud Megújítása",
|
||||||
"subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}",
|
"subscription_expired_at": "Az előfizetésed lejárt: {{date}}",
|
||||||
"no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
|
"no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
|
||||||
"become_subscriber": "Légy Hydra Cloud tag",
|
"become_subscriber": "Légy Hydra Cloud tag",
|
||||||
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
|
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
|
||||||
"subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}",
|
"subscription_renews_on": "Az előfizetésed megújul: {{date}}",
|
||||||
"bill_sent_until": "A következő számlát ezen napon küldjük",
|
"bill_sent_until": "A következő számlát ezen napon küldjük",
|
||||||
"no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
|
"no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
|
||||||
"editor_tab_code": "Code",
|
"editor_tab_code": "Code",
|
||||||
@@ -551,10 +571,19 @@
|
|||||||
"platinum": "Platina",
|
"platinum": "Platina",
|
||||||
"hidden": "Rejtett",
|
"hidden": "Rejtett",
|
||||||
"test_notification": "Értesítés tesztelése",
|
"test_notification": "Értesítés tesztelése",
|
||||||
|
"achievement_sound_volume": "Achievement hangereje",
|
||||||
|
"select_achievement_sound": "Achievement hang kiválasztása",
|
||||||
|
"change_achievement_sound": "Achievement hang megváltoztatása",
|
||||||
|
"remove_achievement_sound": "Achievement hang eltávolítása",
|
||||||
|
"preview_sound": "Hang előnézet",
|
||||||
|
"select": "Kiválaszt",
|
||||||
|
"preview": "Előnézet",
|
||||||
|
"remove": "Eltávolít",
|
||||||
|
"no_sound_file_selected": "Nincs hangfájl kiválasztva",
|
||||||
"notification_preview": "Achievement Értesítés Előnézete",
|
"notification_preview": "Achievement Értesítés Előnézete",
|
||||||
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
|
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
|
||||||
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
|
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
|
||||||
"hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára"
|
"hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Letöltés befejezve",
|
"download_complete": "Letöltés befejezve",
|
||||||
@@ -652,7 +681,7 @@
|
|||||||
"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ő",
|
||||||
@@ -670,7 +699,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:",
|
||||||
"upload_banner": "Borítókép feltöltés",
|
"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",
|
||||||
"stats": "Statisztikák",
|
"stats": "Statisztikák",
|
||||||
@@ -689,7 +718,31 @@
|
|||||||
"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"
|
"karma_description": "Pozitív értékelésekkel szerzett pontok",
|
||||||
|
"user_reviews": "Vélemények",
|
||||||
|
"delete_review": "Vélemény Törlése",
|
||||||
|
"loading_reviews": "Vélemények betöltése..."
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"library": "Könyvtár",
|
||||||
|
"play": "Játék",
|
||||||
|
"download": "Letöltés",
|
||||||
|
"downloading": "Letöltés..",
|
||||||
|
"game": "játék",
|
||||||
|
"games": "játékok",
|
||||||
|
"grid_view": "Rács nézet",
|
||||||
|
"compact_view": "Kompakt nézet",
|
||||||
|
"large_view": "Nagy nézet",
|
||||||
|
"no_games_title": "A könyvtárad üres",
|
||||||
|
"no_games_description": "Adj játékokat a katalógusból hozzá vagy töltsd le őket hogy bele vágj",
|
||||||
|
"amount_hours": "{{amount}} óra",
|
||||||
|
"amount_minutes": "{{amount}} perc",
|
||||||
|
"amount_hours_short": "{{amount}}ó",
|
||||||
|
"amount_minutes_short": "{{amount}}p",
|
||||||
|
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
|
||||||
|
"all_games": "Összes Játék",
|
||||||
|
"recently_played": "Nemrég Játszva",
|
||||||
|
"favorites": "Kedvencek"
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Achievement feloldva",
|
"achievement_unlocked": "Achievement feloldva",
|
||||||
|
|||||||
@@ -93,11 +93,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 +115,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"
|
||||||
@@ -182,6 +191,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",
|
||||||
@@ -394,7 +404,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",
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,8 +93,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": "Настройки",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"downloads": "İndirilenler",
|
"downloads": "İndirilenler",
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
"my_library": "Kütüphanem",
|
"my_library": "Kütüphanem",
|
||||||
|
"library": "Kütüphane",
|
||||||
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
|
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
|
||||||
"paused": "{{title}} (Duraklatıldı)",
|
"paused": "{{title}} (Duraklatıldı)",
|
||||||
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
|
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
|
||||||
@@ -26,7 +27,69 @@
|
|||||||
"sign_in": "Giriş Yap",
|
"sign_in": "Giriş Yap",
|
||||||
"friends": "Arkadaşlar",
|
"friends": "Arkadaşlar",
|
||||||
"need_help": "Yardıma mı ihtiyacınız var?",
|
"need_help": "Yardıma mı ihtiyacınız var?",
|
||||||
"favorites": "Favoriler"
|
"favorites": "Favoriler",
|
||||||
|
"playable_button_title": "Şu anda oynayabileceğin oyunları göster",
|
||||||
|
"add_custom_game_tooltip": "Özel Oyun Ekle",
|
||||||
|
"show_playable_only_tooltip": "Sadece Oynanabilirleri Göster",
|
||||||
|
"custom_game_modal": "Özel Oyun Ekle",
|
||||||
|
"custom_game_modal_description": "Çalıştırılabilir bir dosya seçerek kütüphanene özel oyun ekle",
|
||||||
|
"custom_game_modal_executable_path": "Çalıştırılabilir Dosya Yolu",
|
||||||
|
"custom_game_modal_select_executable": "Çalıştırılabilir dosya seç",
|
||||||
|
"custom_game_modal_title": "Başlık",
|
||||||
|
"custom_game_modal_enter_title": "Başlık gir",
|
||||||
|
"custom_game_modal_browse": "Gözat",
|
||||||
|
"custom_game_modal_cancel": "İptal",
|
||||||
|
"custom_game_modal_add": "Oyun Ekle",
|
||||||
|
"custom_game_modal_adding": "Oyun Ekleniyor...",
|
||||||
|
"custom_game_modal_success": "Özel oyun başarıyla eklendi",
|
||||||
|
"custom_game_modal_failed": "Özel oyun eklenemedi",
|
||||||
|
"custom_game_modal_executable": "Çalıştırılabilir",
|
||||||
|
"edit_game_modal": "Varlıkları Özelleştir",
|
||||||
|
"edit_game_modal_description": "Oyun varlıklarını ve detaylarını özelleştir",
|
||||||
|
"edit_game_modal_title": "Başlık",
|
||||||
|
"edit_game_modal_enter_title": "Başlık gir",
|
||||||
|
"edit_game_modal_image": "Görsel",
|
||||||
|
"edit_game_modal_select_image": "Görsel seç",
|
||||||
|
"edit_game_modal_browse": "Gözat",
|
||||||
|
"edit_game_modal_image_preview": "Görsel önizleme",
|
||||||
|
"edit_game_modal_icon": "İkon",
|
||||||
|
"edit_game_modal_select_icon": "İkon seç",
|
||||||
|
"edit_game_modal_icon_preview": "İkon önizleme",
|
||||||
|
"edit_game_modal_logo": "Logo",
|
||||||
|
"edit_game_modal_select_logo": "Logo seç",
|
||||||
|
"edit_game_modal_logo_preview": "Logo önizleme",
|
||||||
|
"edit_game_modal_hero": "Kütüphane Hero",
|
||||||
|
"edit_game_modal_select_hero": "Kütüphane hero görseli seç",
|
||||||
|
"edit_game_modal_hero_preview": "Kütüphane hero görseli önizleme",
|
||||||
|
"edit_game_modal_cancel": "İptal et",
|
||||||
|
"edit_game_modal_update": "Güncelle",
|
||||||
|
"edit_game_modal_updating": "Güncelleniyor...",
|
||||||
|
"edit_game_modal_fill_required": "Lütfen tüm gerekli alanları doldur",
|
||||||
|
"edit_game_modal_success": "Varlıklar başarıyla güncellendi",
|
||||||
|
"edit_game_modal_failed": "Varlıklar güncellenemedi",
|
||||||
|
"edit_game_modal_image_filter": "Görsel",
|
||||||
|
"edit_game_modal_icon_resolution": "Önerilen çözünürlük: 256x256px",
|
||||||
|
"edit_game_modal_logo_resolution": "Önerilen çözünürlük: 640x360px",
|
||||||
|
"edit_game_modal_hero_resolution": "Önerilen çözünürlük: 1920x620px",
|
||||||
|
"edit_game_modal_assets": "Varlıklar",
|
||||||
|
"edit_game_modal_drop_icon_image_here": "İkon görselini buraya bırak",
|
||||||
|
"edit_game_modal_drop_logo_image_here": "Logo görselini buraya bırak",
|
||||||
|
"edit_game_modal_drop_hero_image_here": "Hero görselini buraya bırak",
|
||||||
|
"edit_game_modal_drop_to_replace_icon": "İkonu değiştirmek için buraya bırak",
|
||||||
|
"edit_game_modal_drop_to_replace_logo": "Logoyu değiştirmek için buraya bırak",
|
||||||
|
"edit_game_modal_drop_to_replace_hero": "Hero'yu değiştirmek için buraya bırak",
|
||||||
|
"install_decky_plugin": "Decky Plugin Kur",
|
||||||
|
"update_decky_plugin": "Decky Plugin Güncelle",
|
||||||
|
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
||||||
|
"install_decky_plugin_title": "Hydra Decky Plugin Kur",
|
||||||
|
"install_decky_plugin_message": "Bu işlem Decky Loader için Hydra plugin'ini indirecek ve kuracak. Bu işlem yükseltilmiş izinler gerektirebilir. Devam et?",
|
||||||
|
"update_decky_plugin_title": "Hydra Decky Plugin Güncelle",
|
||||||
|
"update_decky_plugin_message": "Hydra Decky plugin'inin yeni bir sürümü mevcut. Şimdi güncellemek ister misin?",
|
||||||
|
"decky_plugin_installed": "Decky plugin v{{version}} başarıyla kuruldu",
|
||||||
|
"decky_plugin_installation_failed": "Decky plugin kurulamadı: {{error}}",
|
||||||
|
"decky_plugin_installation_error": "Decky plugin kurulumu hatası: {{error}}",
|
||||||
|
"confirm": "Onayla",
|
||||||
|
"cancel": "İptal"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"search": "Oyunlarda Ara",
|
"search": "Oyunlarda Ara",
|
||||||
@@ -35,6 +98,8 @@
|
|||||||
"downloads": "İndirilenler",
|
"downloads": "İndirilenler",
|
||||||
"search_results": "Arama Sonuçları",
|
"search_results": "Arama Sonuçları",
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
|
"search_library": "Kütüphanede ara",
|
||||||
|
"library": "Kütüphane",
|
||||||
"version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.",
|
"version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.",
|
||||||
"version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın."
|
"version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın."
|
||||||
},
|
},
|
||||||
@@ -203,7 +268,108 @@
|
|||||||
"create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur",
|
"create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur",
|
||||||
"invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu",
|
"invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu",
|
||||||
"invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.",
|
"invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.",
|
||||||
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir"
|
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir",
|
||||||
|
"already_in_library": "Zaten kütüphanede",
|
||||||
|
"create_shortcut_simple": "Kısayol oluştur",
|
||||||
|
"properties": "Özellikler",
|
||||||
|
"new_download_option": "Yeni",
|
||||||
|
"add_to_favorites": "Favorilere ekle",
|
||||||
|
"remove_from_favorites": "Favorilerden çıkar",
|
||||||
|
"failed_update_favorites": "Favoriler güncellenemedi",
|
||||||
|
"game_removed_from_library": "Oyun kütüphaneden çıkarıldı",
|
||||||
|
"failed_remove_from_library": "Kütüphaneden çıkarılamadı",
|
||||||
|
"files_removed_success": "Dosyalar başarıyla kaldırıldı",
|
||||||
|
"failed_remove_files": "Dosyalar kaldırılamadı",
|
||||||
|
"rating_count": "Puan",
|
||||||
|
"show_more": "Daha fazla göster",
|
||||||
|
"show_less": "Daha az göster",
|
||||||
|
"reviews": "İncelemeler",
|
||||||
|
"review_played_for": "Oynama süresi",
|
||||||
|
"leave_a_review": "İnceleme Yap",
|
||||||
|
"write_review_placeholder": "Bu oyun hakkındaki düşüncelerini paylaş...",
|
||||||
|
"sort_newest": "En yeni",
|
||||||
|
"no_reviews_yet": "Henüz inceleme yok",
|
||||||
|
"be_first_to_review": "Bu oyun hakkındaki düşüncelerini paylaşan ilk kişi ol!",
|
||||||
|
"sort_oldest": "En eski",
|
||||||
|
"sort_highest_score": "En yüksek puan",
|
||||||
|
"sort_lowest_score": "En düşük puan",
|
||||||
|
"sort_most_voted": "En çok oy",
|
||||||
|
"rating": "Puan",
|
||||||
|
"rating_stats": "Puan",
|
||||||
|
"rating_very_negative": "Çok Olumsuz",
|
||||||
|
"rating_negative": "Olumsuz",
|
||||||
|
"rating_neutral": "Nötr",
|
||||||
|
"rating_positive": "Olumlu",
|
||||||
|
"rating_very_positive": "Çok Olumlu",
|
||||||
|
"submit_review": "Gönder",
|
||||||
|
"submitting": "Gönderiliyor...",
|
||||||
|
"review_submitted_successfully": "İnceleme başarıyla gönderildi!",
|
||||||
|
"review_submission_failed": "İnceleme gönderilemedi. Lütfen tekrar dene.",
|
||||||
|
"review_cannot_be_empty": "İnceleme metin alanı boş olamaz.",
|
||||||
|
"review_deleted_successfully": "İnceleme başarıyla silindi.",
|
||||||
|
"review_deletion_failed": "İnceleme silinemedi. Lütfen tekrar dene.",
|
||||||
|
"loading_reviews": "İncelemeler yükleniyor...",
|
||||||
|
"loading_more_reviews": "Daha fazla inceleme yükleniyor...",
|
||||||
|
"load_more_reviews": "Daha fazla inceleme yükle",
|
||||||
|
"you_seemed_to_enjoy_this_game": "Bu oyunu beğenmiş görünüyorsun",
|
||||||
|
"would_you_recommend_this_game": "Bu oyun hakkında bir inceleme yazmak ister misin?",
|
||||||
|
"yes": "Evet",
|
||||||
|
"maybe_later": "Belki sonra",
|
||||||
|
"backup_failed": "Yedekleme başarısız",
|
||||||
|
"update_playtime_title": "Oynama süresini güncelle",
|
||||||
|
"update_playtime_description": "{{game}} için oynama süresini manuel olarak güncelle",
|
||||||
|
"update_playtime": "Oynama süresini güncelle",
|
||||||
|
"update_playtime_success": "Oynama süresi başarıyla güncellendi",
|
||||||
|
"update_playtime_error": "Oynama süresi güncellenemedi",
|
||||||
|
"update_game_playtime": "Oyun oynama süresini güncelle",
|
||||||
|
"manual_playtime_warning": "Saatlerin manuel olarak güncellendiği işaretlenecek ve bu geri alınamaz.",
|
||||||
|
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
|
||||||
|
"game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı",
|
||||||
|
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
|
||||||
|
"artifact_renamed": "Yedekleme başarıyla yeniden adlandırıldı",
|
||||||
|
"rename_artifact": "Yedeklemeyi Yeniden Adlandır",
|
||||||
|
"rename_artifact_description": "Yedeklemeyi daha açıklayıcı bir isimle yeniden adlandır",
|
||||||
|
"artifact_name_label": "Yedekleme adı",
|
||||||
|
"artifact_name_placeholder": "Yedekleme için bir isim gir",
|
||||||
|
"save_changes": "Değişiklikleri kaydet",
|
||||||
|
"required_field": "Bu alan gereklidir",
|
||||||
|
"max_length_field": "Bu alan {{length}} karakterden az olmalıdır",
|
||||||
|
"freeze_backup": "Otomatik yedeklemeler tarafından üzerine yazılmasın diye sabitle",
|
||||||
|
"unfreeze_backup": "Sabitlemeyi kaldır",
|
||||||
|
"backup_frozen": "Yedekleme sabitlendi",
|
||||||
|
"backup_unfrozen": "Yedekleme sabitlemesi kaldırıldı",
|
||||||
|
"backup_freeze_failed": "Yedekleme sabitlenemedi",
|
||||||
|
"backup_freeze_failed_description": "Otomatik yedeklemeler için en az bir boş alan bırakmalısın",
|
||||||
|
"edit_game_modal_button": "Oyun varlıklarını özelleştir",
|
||||||
|
"game_details": "Oyun Detayları",
|
||||||
|
"currency_symbol": "₺",
|
||||||
|
"currency_country": "tr",
|
||||||
|
"prices": "Fiyatlar",
|
||||||
|
"no_prices_found": "Fiyat bulunamadı",
|
||||||
|
"view_all_prices": "Tüm fiyatları görüntülemek için tıkla",
|
||||||
|
"retail_price": "Perakende fiyatı",
|
||||||
|
"keyshop_price": "Anahtar dükkanı fiyatı",
|
||||||
|
"historical_retail": "Geçmiş perakende",
|
||||||
|
"historical_keyshop": "Geçmiş anahtar dükkanı",
|
||||||
|
"language": "Dil",
|
||||||
|
"caption": "Altyazı",
|
||||||
|
"audio": "Ses",
|
||||||
|
"filter_by_source": "Kaynağa göre filtrele",
|
||||||
|
"no_repacks_found": "Bu oyun için kaynak bulunamadı",
|
||||||
|
"delete_review": "İncelemeyi sil",
|
||||||
|
"remove_review": "İncelemeyi Kaldır",
|
||||||
|
"delete_review_modal_title": "İncelemeni silmek istediğinden emin misin?",
|
||||||
|
"delete_review_modal_description": "Bu işlem geri alınamaz.",
|
||||||
|
"delete_review_modal_delete_button": "Sil",
|
||||||
|
"delete_review_modal_cancel_button": "İptal",
|
||||||
|
"vote_failed": "Oyun kaydı başarısız oldu. Lütfen tekrar dene.",
|
||||||
|
"show_original": "Orijinali göster",
|
||||||
|
"show_translation": "Çeviriyi göster",
|
||||||
|
"show_original_translated_from": "Orijinali göster ({{language}} dilinden çevrilmiştir)",
|
||||||
|
"hide_original": "Orijinali gizle",
|
||||||
|
"review_from_blocked_user": "Engellenen kullanıcıdan gelen inceleme",
|
||||||
|
"show": "Göster",
|
||||||
|
"hide": "Gizle"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Hydra'yı Etkinleştir",
|
"title": "Hydra'yı Etkinleştir",
|
||||||
@@ -379,7 +545,33 @@
|
|||||||
"hidden": "Gizli",
|
"hidden": "Gizli",
|
||||||
"test_notification": "Test bildirimi",
|
"test_notification": "Test bildirimi",
|
||||||
"notification_preview": "Başarı Bildirimi Önizlemesi",
|
"notification_preview": "Başarı Bildirimi Önizlemesi",
|
||||||
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında"
|
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında",
|
||||||
|
"adding": "Ekleniyor…",
|
||||||
|
"failed_add_download_source": "İndirme kaynağı eklenemedi. Lütfen tekrar dene.",
|
||||||
|
"download_source_already_exists": "Bu indirme kaynağı URL'si zaten mevcut.",
|
||||||
|
"download_source_pending_matching": "Yakında güncellenecek",
|
||||||
|
"download_source_matched": "Güncel",
|
||||||
|
"download_source_matching": "Güncelleniyor",
|
||||||
|
"download_source_failed": "Hata",
|
||||||
|
"download_source_no_information": "Bilgi mevcut değil",
|
||||||
|
"removed_all_download_sources": "Tüm indirme kaynakları kaldırıldı",
|
||||||
|
"download_sources_synced_successfully": "Tüm indirme kaynakları senkronize edildi",
|
||||||
|
"importing": "İçe aktarılıyor...",
|
||||||
|
"hydra_cloud": "Hydra Cloud",
|
||||||
|
"debrid": "Debrid",
|
||||||
|
"debrid_description": "Debrid servisleri, internet hızınızla sınırlı, çeşitli dosya barındırma hizmetlerinde barındırılan dosyaları hızla indirmenize olanak tanıyan premium sınırsız indiricilerdir.",
|
||||||
|
"enable_steam_achievements": "Steam başarımları aramasını etkinleştir",
|
||||||
|
"achievement_sound_volume": "Başarım ses seviyesi",
|
||||||
|
"select_achievement_sound": "Başarım sesi seç",
|
||||||
|
"change_achievement_sound": "Başarım sesini değiştir",
|
||||||
|
"remove_achievement_sound": "Başarım sesini kaldır",
|
||||||
|
"preview_sound": "Sesi önizle",
|
||||||
|
"select": "Seç",
|
||||||
|
"preview": "Önizle",
|
||||||
|
"remove": "Kaldır",
|
||||||
|
"no_sound_file_selected": "Ses dosyası seçilmedi",
|
||||||
|
"autoplay_trailers_on_game_page": "Oyun sayfasında fragmanları otomatik olarak oynat",
|
||||||
|
"hide_to_tray_on_game_start": "Oyun başlatıldığında Hydra'yı sistem tepsisine gizle"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "İndirme tamamlandı",
|
"download_complete": "İndirme tamamlandı",
|
||||||
@@ -406,7 +598,8 @@
|
|||||||
"game_card": {
|
"game_card": {
|
||||||
"available_one": "Mevcut",
|
"available_one": "Mevcut",
|
||||||
"available_other": "Mevcut",
|
"available_other": "Mevcut",
|
||||||
"no_downloads": "İndirme mevcut değil"
|
"no_downloads": "İndirme mevcut değil",
|
||||||
|
"calculating": "Hesaplanıyor"
|
||||||
},
|
},
|
||||||
"binary_not_found_modal": {
|
"binary_not_found_modal": {
|
||||||
"title": "Programlar Yüklü Değil",
|
"title": "Programlar Yüklü Değil",
|
||||||
@@ -498,7 +691,46 @@
|
|||||||
"achievements_unlocked": "Açılan başarımlar",
|
"achievements_unlocked": "Açılan başarımlar",
|
||||||
"earned_points": "Kazanılan puanlar",
|
"earned_points": "Kazanılan puanlar",
|
||||||
"show_achievements_on_profile": "Başarımlarını profilinde göster",
|
"show_achievements_on_profile": "Başarımlarını profilinde göster",
|
||||||
"show_points_on_profile": "Kazanılan puanlarını profilinde göster"
|
"show_points_on_profile": "Kazanılan puanlarını profilinde göster",
|
||||||
|
"amount_hours_short": "{{amount}}s",
|
||||||
|
"amount_minutes_short": "{{amount}}d",
|
||||||
|
"pinned": "Sabitlenmiş",
|
||||||
|
"sort_by": "Sırala:",
|
||||||
|
"achievements_earned": "Kazanılan başarımlar",
|
||||||
|
"played_recently": "Son oynanan",
|
||||||
|
"playtime": "Oynama süresi",
|
||||||
|
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
|
||||||
|
"error_adding_friend": "Arkadaş isteği gönderilemedi. Lütfen arkadaş kodunu kontrol et",
|
||||||
|
"friend_code_length_error": "Arkadaş kodu 8 karakter olmalıdır",
|
||||||
|
"game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı",
|
||||||
|
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
|
||||||
|
"karma": "Karma",
|
||||||
|
"karma_count": "karma",
|
||||||
|
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
|
||||||
|
"user_reviews": "İncelemeler",
|
||||||
|
"delete_review": "İncelemeyi Sil",
|
||||||
|
"loading_reviews": "İncelemeler yükleniyor..."
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"library": "Kütüphane",
|
||||||
|
"play": "Oyna",
|
||||||
|
"download": "İndir",
|
||||||
|
"downloading": "İndiriliyor",
|
||||||
|
"game": "oyun",
|
||||||
|
"games": "oyunlar",
|
||||||
|
"grid_view": "Izgara görünümü",
|
||||||
|
"compact_view": "Kompakt görünüm",
|
||||||
|
"large_view": "Büyük görünüm",
|
||||||
|
"no_games_title": "Kütüphanen boş",
|
||||||
|
"no_games_description": "Başlamak için katalogdan oyun ekle veya indir",
|
||||||
|
"amount_hours": "{{amount}} saat",
|
||||||
|
"amount_minutes": "{{amount}} dakika",
|
||||||
|
"amount_hours_short": "{{amount}}s",
|
||||||
|
"amount_minutes_short": "{{amount}}d",
|
||||||
|
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
|
||||||
|
"all_games": "Tüm Oyunlar",
|
||||||
|
"recently_played": "Son Oynanan",
|
||||||
|
"favorites": "Favoriler"
|
||||||
},
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Başarım açıldı",
|
"achievement_unlocked": "Başarım açıldı",
|
||||||
|
|||||||
3
src/main/events/auth/index.ts
Normal file
3
src/main/events/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import "./get-session-hash";
|
||||||
|
import "./open-auth-window";
|
||||||
|
import "./sign-out";
|
||||||
2
src/main/events/autoupdater/index.ts
Normal file
2
src/main/events/autoupdater/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "./check-for-updates";
|
||||||
|
import "./restart-and-install-update";
|
||||||
4
src/main/events/catalogue/index.ts
Normal file
4
src/main/events/catalogue/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import "./get-game-assets";
|
||||||
|
import "./get-game-shop-details";
|
||||||
|
import "./get-game-stats";
|
||||||
|
import "./get-random-game";
|
||||||
4
src/main/events/cloud-save/index.ts
Normal file
4
src/main/events/cloud-save/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import "./download-game-artifact";
|
||||||
|
import "./get-game-backup-preview";
|
||||||
|
import "./select-game-backup-path";
|
||||||
|
import "./upload-save-game";
|
||||||
6
src/main/events/download-sources/index.ts
Normal file
6
src/main/events/download-sources/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import "./add-download-source";
|
||||||
|
import "./get-download-sources-check-baseline";
|
||||||
|
import "./get-download-sources-since-value";
|
||||||
|
import "./get-download-sources";
|
||||||
|
import "./remove-download-source";
|
||||||
|
import "./sync-download-sources";
|
||||||
2
src/main/events/hardware/index.ts
Normal file
2
src/main/events/hardware/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "./check-folder-write-permission";
|
||||||
|
import "./get-disk-free-space";
|
||||||
@@ -1,107 +1,22 @@
|
|||||||
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
|
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
|
||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
|
|
||||||
import "./catalogue/get-game-shop-details";
|
import "./auth";
|
||||||
import "./catalogue/get-random-game";
|
import "./autoupdater";
|
||||||
import "./catalogue/get-game-stats";
|
import "./catalogue";
|
||||||
import "./hardware/get-disk-free-space";
|
import "./cloud-save";
|
||||||
import "./hardware/check-folder-write-permission";
|
import "./download-sources";
|
||||||
import "./library/add-game-to-library";
|
import "./hardware";
|
||||||
import "./library/add-custom-game-to-library";
|
import "./library";
|
||||||
import "./library/update-custom-game";
|
import "./leveldb";
|
||||||
import "./library/update-game-custom-assets";
|
import "./misc";
|
||||||
import "./library/add-game-to-favorites";
|
import "./notifications";
|
||||||
import "./library/remove-game-from-favorites";
|
import "./profile";
|
||||||
import "./library/toggle-game-pin";
|
import "./themes";
|
||||||
import "./library/create-game-shortcut";
|
import "./torrenting";
|
||||||
import "./library/close-game";
|
import "./user";
|
||||||
import "./library/delete-game-folder";
|
import "./user-preferences";
|
||||||
import "./library/get-game-by-object-id";
|
|
||||||
import "./library/get-library";
|
|
||||||
import "./library/refresh-library-assets";
|
|
||||||
import "./library/extract-game-download";
|
|
||||||
import "./library/clear-new-download-options";
|
|
||||||
import "./library/open-game";
|
|
||||||
import "./library/open-game-executable-path";
|
|
||||||
import "./library/open-game-installer";
|
|
||||||
import "./library/open-game-installer-path";
|
|
||||||
import "./library/update-executable-path";
|
|
||||||
import "./library/update-launch-options";
|
|
||||||
import "./library/verify-executable-path";
|
|
||||||
import "./library/remove-game";
|
|
||||||
import "./library/remove-game-from-library";
|
|
||||||
import "./library/select-game-wine-prefix";
|
|
||||||
import "./library/reset-game-achievements";
|
|
||||||
import "./library/change-game-playtime";
|
|
||||||
import "./library/toggle-automatic-cloud-sync";
|
|
||||||
import "./library/get-default-wine-prefix-selection-path";
|
|
||||||
import "./library/cleanup-unused-assets";
|
|
||||||
import "./library/create-steam-shortcut";
|
|
||||||
import "./library/copy-custom-game-asset";
|
|
||||||
import "./misc/open-checkout";
|
|
||||||
import "./misc/open-external";
|
|
||||||
import "./misc/show-open-dialog";
|
|
||||||
import "./misc/show-item-in-folder";
|
|
||||||
import "./misc/install-common-redist";
|
|
||||||
import "./misc/can-install-common-redist";
|
|
||||||
import "./misc/save-temp-file";
|
|
||||||
import "./misc/delete-temp-file";
|
|
||||||
import "./misc/install-hydra-decky-plugin";
|
|
||||||
import "./misc/get-hydra-decky-plugin-info";
|
|
||||||
import "./misc/check-homebrew-folder-exists";
|
|
||||||
import "./misc/hydra-api-call";
|
|
||||||
import "./torrenting/cancel-game-download";
|
|
||||||
import "./torrenting/pause-game-download";
|
|
||||||
import "./torrenting/resume-game-download";
|
|
||||||
import "./torrenting/start-game-download";
|
|
||||||
import "./torrenting/pause-game-seed";
|
|
||||||
import "./torrenting/resume-game-seed";
|
|
||||||
import "./torrenting/check-debrid-availability";
|
|
||||||
import "./user-preferences/get-user-preferences";
|
|
||||||
import "./user-preferences/update-user-preferences";
|
|
||||||
import "./user-preferences/auto-launch";
|
|
||||||
import "./autoupdater/check-for-updates";
|
|
||||||
import "./autoupdater/restart-and-install-update";
|
|
||||||
import "./user-preferences/authenticate-real-debrid";
|
|
||||||
import "./user-preferences/authenticate-torbox";
|
|
||||||
import "./download-sources/add-download-source";
|
|
||||||
import "./download-sources/sync-download-sources";
|
|
||||||
import "./download-sources/get-download-sources-check-baseline";
|
|
||||||
import "./download-sources/get-download-sources-since-value";
|
|
||||||
import "./auth/sign-out";
|
|
||||||
import "./auth/open-auth-window";
|
|
||||||
import "./auth/get-session-hash";
|
|
||||||
import "./user/get-auth";
|
|
||||||
import "./user/get-unlocked-achievements";
|
|
||||||
import "./user/get-compared-unlocked-achievements";
|
|
||||||
import "./profile/get-me";
|
|
||||||
import "./profile/update-profile";
|
|
||||||
import "./profile/process-profile-image";
|
|
||||||
import "./profile/sync-friend-requests";
|
|
||||||
import "./cloud-save/download-game-artifact";
|
|
||||||
import "./cloud-save/get-game-backup-preview";
|
|
||||||
import "./cloud-save/upload-save-game";
|
|
||||||
import "./cloud-save/select-game-backup-path";
|
|
||||||
import "./notifications/publish-new-repacks-notification";
|
|
||||||
import "./notifications/update-achievement-notification-window";
|
|
||||||
import "./notifications/show-achievement-test-notification";
|
|
||||||
import "./themes/add-custom-theme";
|
|
||||||
import "./themes/delete-custom-theme";
|
|
||||||
import "./themes/get-all-custom-themes";
|
|
||||||
import "./themes/delete-all-custom-themes";
|
|
||||||
import "./themes/update-custom-theme";
|
|
||||||
import "./themes/open-editor-window";
|
|
||||||
import "./themes/get-custom-theme-by-id";
|
|
||||||
import "./themes/get-active-custom-theme";
|
|
||||||
import "./themes/close-editor-window";
|
|
||||||
import "./themes/toggle-custom-theme";
|
|
||||||
import "./themes/copy-theme-achievement-sound";
|
|
||||||
import "./themes/remove-theme-achievement-sound";
|
|
||||||
import "./themes/get-theme-sound-path";
|
|
||||||
import "./themes/get-theme-sound-data-url";
|
|
||||||
import "./themes/import-theme-sound-from-store";
|
|
||||||
import "./download-sources/remove-download-source";
|
|
||||||
import "./download-sources/get-download-sources";
|
|
||||||
import { isPortableVersion } from "@main/helpers";
|
import { isPortableVersion } from "@main/helpers";
|
||||||
|
|
||||||
ipcMain.handle("ping", () => "pong");
|
ipcMain.handle("ping", () => "pong");
|
||||||
|
|||||||
27
src/main/events/leveldb/helpers.ts
Normal file
27
src/main/events/leveldb/helpers.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { db } from "@main/level";
|
||||||
|
|
||||||
|
const sublevelCache = new Map<
|
||||||
|
string,
|
||||||
|
ReturnType<typeof db.sublevel<string, unknown>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a sublevel by name, creating it if it doesn't exist.
|
||||||
|
* All sublevels use "json" encoding by default.
|
||||||
|
* @param sublevelName - The name of the sublevel to get or create
|
||||||
|
* @returns The sublevel instance
|
||||||
|
*/
|
||||||
|
export const getSublevelByName = (
|
||||||
|
sublevelName: string
|
||||||
|
): ReturnType<typeof db.sublevel<string, unknown>> => {
|
||||||
|
if (sublevelCache.has(sublevelName)) {
|
||||||
|
return sublevelCache.get(sublevelName)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All sublevels use "json" encoding - this cannot be changed per sublevel
|
||||||
|
const sublevel = db.sublevel<string, unknown>(sublevelName, {
|
||||||
|
valueEncoding: "json",
|
||||||
|
});
|
||||||
|
sublevelCache.set(sublevelName, sublevel);
|
||||||
|
return sublevel;
|
||||||
|
};
|
||||||
6
src/main/events/leveldb/index.ts
Normal file
6
src/main/events/leveldb/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import "./leveldb-get";
|
||||||
|
import "./leveldb-put";
|
||||||
|
import "./leveldb-del";
|
||||||
|
import "./leveldb-clear";
|
||||||
|
import "./leveldb-values";
|
||||||
|
import "./leveldb-iterator";
|
||||||
18
src/main/events/leveldb/leveldb-clear.ts
Normal file
18
src/main/events/leveldb/leveldb-clear.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { getSublevelByName } from "./helpers";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const leveldbClear = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
sublevelName: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const sublevel = getSublevelByName(sublevelName);
|
||||||
|
await sublevel.clear();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in leveldbClear", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("leveldbClear", leveldbClear);
|
||||||
28
src/main/events/leveldb/leveldb-del.ts
Normal file
28
src/main/events/leveldb/leveldb-del.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { db } from "@main/level";
|
||||||
|
import { getSublevelByName } from "./helpers";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const leveldbDel = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
key: string,
|
||||||
|
sublevelName?: string | null
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (sublevelName) {
|
||||||
|
const sublevel = getSublevelByName(sublevelName);
|
||||||
|
await sublevel.del(key);
|
||||||
|
} else {
|
||||||
|
await db.del(key);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "NotFoundError") {
|
||||||
|
// NotFoundError on delete is not an error, just return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error("Error in leveldbDel", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("leveldbDel", leveldbDel);
|
||||||
28
src/main/events/leveldb/leveldb-get.ts
Normal file
28
src/main/events/leveldb/leveldb-get.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { db } from "@main/level";
|
||||||
|
import { getSublevelByName } from "./helpers";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const leveldbGet = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
key: string,
|
||||||
|
sublevelName?: string | null,
|
||||||
|
valueEncoding: "json" | "utf8" = "json"
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (sublevelName) {
|
||||||
|
// Note: sublevels always use "json" encoding, valueEncoding parameter is ignored
|
||||||
|
const sublevel = getSublevelByName(sublevelName);
|
||||||
|
return sublevel.get(key);
|
||||||
|
}
|
||||||
|
return db.get<string, unknown>(key, { valueEncoding });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "NotFoundError") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logger.error("Error in leveldbGet", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("leveldbGet", leveldbGet);
|
||||||
18
src/main/events/leveldb/leveldb-iterator.ts
Normal file
18
src/main/events/leveldb/leveldb-iterator.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { getSublevelByName } from "./helpers";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const leveldbIterator = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
sublevelName: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const sublevel = getSublevelByName(sublevelName);
|
||||||
|
return sublevel.iterator().all();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in leveldbIterator", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("leveldbIterator", leveldbIterator);
|
||||||
27
src/main/events/leveldb/leveldb-put.ts
Normal file
27
src/main/events/leveldb/leveldb-put.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { db } from "@main/level";
|
||||||
|
import { getSublevelByName } from "./helpers";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const leveldbPut = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
sublevelName?: string | null,
|
||||||
|
valueEncoding: "json" | "utf8" = "json"
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (sublevelName) {
|
||||||
|
// Note: sublevels always use "json" encoding, valueEncoding parameter is ignored
|
||||||
|
const sublevel = getSublevelByName(sublevelName);
|
||||||
|
await sublevel.put(key, value);
|
||||||
|
} else {
|
||||||
|
await db.put<string, unknown>(key, value, { valueEncoding });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in leveldbPut", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("leveldbPut", leveldbPut);
|
||||||
18
src/main/events/leveldb/leveldb-values.ts
Normal file
18
src/main/events/leveldb/leveldb-values.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { getSublevelByName } from "./helpers";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const leveldbValues = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
sublevelName: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const sublevel = getSublevelByName(sublevelName);
|
||||||
|
return sublevel.values().all();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in leveldbValues", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("leveldbValues", leveldbValues);
|
||||||
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);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { LibraryGame } from "@types";
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import {
|
import {
|
||||||
downloadsSublevel,
|
downloadsSublevel,
|
||||||
|
gameAchievementsSublevel,
|
||||||
gamesShopAssetsSublevel,
|
gamesShopAssetsSublevel,
|
||||||
gamesSublevel,
|
gamesSublevel,
|
||||||
gameAchievementsSublevel,
|
|
||||||
} from "@main/level";
|
} from "@main/level";
|
||||||
|
|
||||||
const getLibrary = async (): Promise<LibraryGame[]> => {
|
const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||||
@@ -19,18 +19,13 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
|||||||
const download = await downloadsSublevel.get(key);
|
const download = await downloadsSublevel.get(key);
|
||||||
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
||||||
|
|
||||||
let unlockedAchievementCount = 0;
|
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
|
||||||
let achievementCount = 0;
|
|
||||||
|
|
||||||
try {
|
if (!game.unlockedAchievementCount) {
|
||||||
const achievements = await gameAchievementsSublevel.get(key);
|
const achievements = await gameAchievementsSublevel.get(key);
|
||||||
if (achievements) {
|
|
||||||
achievementCount = achievements.achievements.length;
|
unlockedAchievementCount =
|
||||||
unlockedAchievementCount =
|
achievements?.unlockedAchievements.length ?? 0;
|
||||||
achievements.unlockedAchievements.length;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No achievements data for this game
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -38,14 +33,14 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
|||||||
...game,
|
...game,
|
||||||
download: download ?? null,
|
download: download ?? null,
|
||||||
unlockedAchievementCount,
|
unlockedAchievementCount,
|
||||||
achievementCount,
|
achievementCount: game.achievementCount ?? 0,
|
||||||
// Spread gameAssets last to ensure all image URLs are properly set
|
// Spread gameAssets last to ensure all image URLs are properly set
|
||||||
...gameAssets,
|
...gameAssets,
|
||||||
// Preserve custom image URLs from game if they exist
|
// Preserve custom image URLs from game if they exist
|
||||||
customIconUrl: game.customIconUrl,
|
customIconUrl: game.customIconUrl,
|
||||||
customLogoImageUrl: game.customLogoImageUrl,
|
customLogoImageUrl: game.customLogoImageUrl,
|
||||||
customHeroImageUrl: game.customHeroImageUrl,
|
customHeroImageUrl: game.customHeroImageUrl,
|
||||||
} as LibraryGame;
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
33
src/main/events/library/index.ts
Normal file
33
src/main/events/library/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import "./add-custom-game-to-library";
|
||||||
|
import "./add-game-to-favorites";
|
||||||
|
import "./add-game-to-library";
|
||||||
|
import "./change-game-playtime";
|
||||||
|
import "./cleanup-unused-assets";
|
||||||
|
import "./clear-new-download-options";
|
||||||
|
import "./close-game";
|
||||||
|
import "./copy-custom-game-asset";
|
||||||
|
import "./create-game-shortcut";
|
||||||
|
import "./create-steam-shortcut";
|
||||||
|
import "./delete-archive";
|
||||||
|
import "./delete-game-folder";
|
||||||
|
import "./extract-game-download";
|
||||||
|
import "./get-default-wine-prefix-selection-path";
|
||||||
|
import "./get-game-by-object-id";
|
||||||
|
import "./get-library";
|
||||||
|
import "./open-game-executable-path";
|
||||||
|
import "./open-game-installer-path";
|
||||||
|
import "./open-game-installer";
|
||||||
|
import "./open-game";
|
||||||
|
import "./refresh-library-assets";
|
||||||
|
import "./remove-game-from-favorites";
|
||||||
|
import "./remove-game-from-library";
|
||||||
|
import "./remove-game";
|
||||||
|
import "./reset-game-achievements";
|
||||||
|
import "./select-game-wine-prefix";
|
||||||
|
import "./toggle-automatic-cloud-sync";
|
||||||
|
import "./toggle-game-pin";
|
||||||
|
import "./update-custom-game";
|
||||||
|
import "./update-executable-path";
|
||||||
|
import "./update-game-custom-assets";
|
||||||
|
import "./update-launch-options";
|
||||||
|
import "./verify-executable-path";
|
||||||
12
src/main/events/misc/index.ts
Normal file
12
src/main/events/misc/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import "./can-install-common-redist";
|
||||||
|
import "./check-homebrew-folder-exists";
|
||||||
|
import "./delete-temp-file";
|
||||||
|
import "./get-hydra-decky-plugin-info";
|
||||||
|
import "./hydra-api-call";
|
||||||
|
import "./install-common-redist";
|
||||||
|
import "./install-hydra-decky-plugin";
|
||||||
|
import "./open-checkout";
|
||||||
|
import "./open-external";
|
||||||
|
import "./save-temp-file";
|
||||||
|
import "./show-item-in-folder";
|
||||||
|
import "./show-open-dialog";
|
||||||
@@ -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);
|
||||||
9
src/main/events/notifications/index.ts
Normal file
9
src/main/events/notifications/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "./publish-new-repacks-notification";
|
||||||
|
import "./show-achievement-test-notification";
|
||||||
|
import "./update-achievement-notification-window";
|
||||||
|
import "./get-local-notifications";
|
||||||
|
import "./get-local-notifications-count";
|
||||||
|
import "./mark-local-notification-read";
|
||||||
|
import "./mark-all-local-notifications-read";
|
||||||
|
import "./delete-local-notification";
|
||||||
|
import "./clear-all-local-notifications";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const markAllLocalNotificationsRead = async () => {
|
||||||
|
await LocalNotificationManager.markAllAsRead();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { LocalNotificationManager } from "@main/services";
|
||||||
|
|
||||||
|
const markLocalNotificationRead = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
await LocalNotificationManager.markAsRead(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("markLocalNotificationRead", markLocalNotificationRead);
|
||||||
3
src/main/events/profile/index.ts
Normal file
3
src/main/events/profile/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import "./get-me";
|
||||||
|
import "./process-profile-image";
|
||||||
|
import "./update-profile";
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { PythonRPC } from "@main/services/python-rpc";
|
import { PythonRPC } from "@main/services/python-rpc";
|
||||||
|
|
||||||
const processProfileImage = async (
|
const processProfileImageEvent = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
path: string
|
path: string
|
||||||
) => {
|
) => {
|
||||||
|
return processProfileImage(path, "webp");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processProfileImage = async (path: string, extension?: string) => {
|
||||||
return PythonRPC.rpc
|
return PythonRPC.rpc
|
||||||
.post<{
|
.post<{
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}>("/profile-image", { image_path: path })
|
}>("/profile-image", { image_path: path, target_extension: extension })
|
||||||
.then((response) => response.data);
|
.then((response) => response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("processProfileImage", processProfileImage);
|
registerEvent("processProfileImage", processProfileImageEvent);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
15
src/main/events/themes/index.ts
Normal file
15
src/main/events/themes/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import "./add-custom-theme";
|
||||||
|
import "./close-editor-window";
|
||||||
|
import "./copy-theme-achievement-sound";
|
||||||
|
import "./delete-all-custom-themes";
|
||||||
|
import "./delete-custom-theme";
|
||||||
|
import "./get-active-custom-theme";
|
||||||
|
import "./get-all-custom-themes";
|
||||||
|
import "./get-custom-theme-by-id";
|
||||||
|
import "./get-theme-sound-data-url";
|
||||||
|
import "./get-theme-sound-path";
|
||||||
|
import "./import-theme-sound-from-store";
|
||||||
|
import "./open-editor-window";
|
||||||
|
import "./remove-theme-achievement-sound";
|
||||||
|
import "./toggle-custom-theme";
|
||||||
|
import "./update-custom-theme";
|
||||||
7
src/main/events/torrenting/index.ts
Normal file
7
src/main/events/torrenting/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import "./cancel-game-download";
|
||||||
|
import "./check-debrid-availability";
|
||||||
|
import "./pause-game-download";
|
||||||
|
import "./pause-game-seed";
|
||||||
|
import "./resume-game-download";
|
||||||
|
import "./resume-game-seed";
|
||||||
|
import "./start-game-download";
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
src/main/events/user-preferences/index.ts
Normal file
5
src/main/events/user-preferences/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import "./authenticate-real-debrid";
|
||||||
|
import "./authenticate-torbox";
|
||||||
|
import "./auto-launch";
|
||||||
|
import "./get-user-preferences";
|
||||||
|
import "./update-user-preferences";
|
||||||
3
src/main/events/user/index.ts
Normal file
3
src/main/events/user/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import "./get-auth";
|
||||||
|
import "./get-compared-unlocked-achievements";
|
||||||
|
import "./get-unlocked-achievements";
|
||||||
@@ -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)(
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export const getDownloadSourcesCheckBaseline = async (): Promise<
|
|||||||
string | null
|
string | null
|
||||||
> => {
|
> => {
|
||||||
try {
|
try {
|
||||||
const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline);
|
const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, {
|
||||||
|
valueEncoding: "utf8",
|
||||||
|
});
|
||||||
return timestamp;
|
return timestamp;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "NotFoundError") {
|
if (error instanceof Error && error.name === "NotFoundError") {
|
||||||
@@ -27,7 +29,9 @@ export const updateDownloadSourcesCheckBaseline = async (
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const utcTimestamp = new Date(timestamp).toISOString();
|
const utcTimestamp = new Date(timestamp).toISOString();
|
||||||
await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp);
|
await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp, {
|
||||||
|
valueEncoding: "utf8",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gets the 'since' value the API used in the last check (for modal comparison)
|
// Gets the 'since' value the API used in the last check (for modal comparison)
|
||||||
@@ -35,7 +39,9 @@ export const getDownloadSourcesSinceValue = async (): Promise<
|
|||||||
string | null
|
string | null
|
||||||
> => {
|
> => {
|
||||||
try {
|
try {
|
||||||
const timestamp = await db.get(levelKeys.downloadSourcesSinceValue);
|
const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, {
|
||||||
|
valueEncoding: "utf8",
|
||||||
|
});
|
||||||
return timestamp;
|
return timestamp;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "NotFoundError") {
|
if (error instanceof Error && error.name === "NotFoundError") {
|
||||||
@@ -55,5 +61,7 @@ export const updateDownloadSourcesSinceValue = async (
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const utcTimestamp = new Date(timestamp).toISOString();
|
const utcTimestamp = new Date(timestamp).toISOString();
|
||||||
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp);
|
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, {
|
||||||
|
valueEncoding: "utf8",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
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 { UserPreferences } from "@types";
|
||||||
@@ -33,9 +33,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 +57,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,7 +68,7 @@ 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) => {
|
downloads.forEach((download) => {
|
||||||
|
|||||||
@@ -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 = -1) => {
|
||||||
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
|||||||
@@ -24,10 +24,80 @@ 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";
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static downloadingGameId: string | null = null;
|
private static downloadingGameId: string | null = null;
|
||||||
|
|
||||||
|
private static extractFilename(
|
||||||
|
url: string,
|
||||||
|
originalUrl?: string
|
||||||
|
): string | undefined {
|
||||||
|
if (originalUrl?.includes("#")) {
|
||||||
|
const hashPart = originalUrl.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||||
|
return hashPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes("#")) {
|
||||||
|
const hashPart = url.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
|
||||||
|
return hashPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
const pathParts = pathname.split("/");
|
||||||
|
const filename = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
|
if (filename?.includes(".") && filename.length > 0) {
|
||||||
|
return decodeURIComponent(filename);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sanitizeFilename(filename: string): string {
|
||||||
|
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createDownloadPayload(
|
||||||
|
directUrl: string,
|
||||||
|
originalUrl: string,
|
||||||
|
downloadId: string,
|
||||||
|
savePath: string
|
||||||
|
) {
|
||||||
|
const filename =
|
||||||
|
this.extractFilename(originalUrl, directUrl) ||
|
||||||
|
this.extractFilename(directUrl);
|
||||||
|
const sanitizedFilename = filename
|
||||||
|
? this.sanitizeFilename(filename)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (sanitizedFilename) {
|
||||||
|
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||||
|
} else {
|
||||||
|
logger.log(
|
||||||
|
`[DownloadManager] No filename extracted, aria2 will use default`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "start" as const,
|
||||||
|
game_id: downloadId,
|
||||||
|
url: directUrl,
|
||||||
|
save_path: savePath,
|
||||||
|
out: sanitizedFilename,
|
||||||
|
allow_multiple_connections: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static async startRPC(
|
public static async startRPC(
|
||||||
download?: Download,
|
download?: Download,
|
||||||
downloadsToSeed?: Download[]
|
downloadsToSeed?: Download[]
|
||||||
@@ -121,21 +191,14 @@ export class DownloadManager {
|
|||||||
|
|
||||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||||
levelKeys.userPreferences,
|
levelKeys.userPreferences,
|
||||||
{
|
{ valueEncoding: "json" }
|
||||||
valueEncoding: "json",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (WindowManager.mainWindow && download) {
|
if (WindowManager.mainWindow && download) {
|
||||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||||
WindowManager.mainWindow.webContents.send(
|
WindowManager.mainWindow.webContents.send(
|
||||||
"on-download-progress",
|
"on-download-progress",
|
||||||
JSON.parse(
|
JSON.parse(JSON.stringify({ ...status, game }))
|
||||||
JSON.stringify({
|
|
||||||
...status,
|
|
||||||
game,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,27 +242,25 @@ export class DownloadManager {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
gameFilesManager.extractDownloadedFile();
|
gameFilesManager.extractDownloadedFile();
|
||||||
} else {
|
} else if (download.folderName) {
|
||||||
gameFilesManager
|
gameFilesManager
|
||||||
.extractFilesInDirectory(
|
.extractFilesInDirectory(
|
||||||
path.join(download.downloadPath, download.folderName!)
|
path.join(download.downloadPath, download.folderName)
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => gameFilesManager.setExtractionComplete());
|
||||||
gameFilesManager.setExtractionComplete();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloads = await downloadsSublevel
|
const downloads = await downloadsSublevel
|
||||||
.values()
|
.values()
|
||||||
.all()
|
.all()
|
||||||
.then((games) => {
|
.then((games) =>
|
||||||
return sortBy(
|
sortBy(
|
||||||
games.filter((game) => game.status === "paused" && game.queued),
|
games.filter((game) => game.status === "paused" && game.queued),
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"DESC"
|
"DESC"
|
||||||
);
|
)
|
||||||
});
|
);
|
||||||
|
|
||||||
const [nextItemOnQueue] = downloads;
|
const [nextItemOnQueue] = downloads;
|
||||||
|
|
||||||
@@ -267,13 +328,8 @@ export class DownloadManager {
|
|||||||
|
|
||||||
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
||||||
await PythonRPC.rpc
|
await PythonRPC.rpc
|
||||||
.post("/action", {
|
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||||
action: "cancel",
|
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||||
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);
|
||||||
@@ -306,7 +362,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 {
|
||||||
@@ -348,9 +403,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 +463,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 +475,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 +489,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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/main/services/hosters/buzzheavier.ts
Normal file
86
src/main/services/hosters/buzzheavier.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ 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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type ProfileGame = {
|
|||||||
hasManuallyUpdatedPlaytime: boolean;
|
hasManuallyUpdatedPlaytime: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
|
achievementCount: number;
|
||||||
|
unlockedAchievementCount: number;
|
||||||
} & ShopAssets;
|
} & ShopAssets;
|
||||||
|
|
||||||
export const mergeWithRemoteGames = async () => {
|
export const mergeWithRemoteGames = async () => {
|
||||||
@@ -39,6 +41,8 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
playTimeInMilliseconds: updatedPlayTime,
|
playTimeInMilliseconds: updatedPlayTime,
|
||||||
favorite: game.isFavorite ?? localGame.favorite,
|
favorite: game.isFavorite ?? localGame.favorite,
|
||||||
isPinned: game.isPinned ?? localGame.isPinned,
|
isPinned: game.isPinned ?? localGame.isPinned,
|
||||||
|
achievementCount: game.achievementCount,
|
||||||
|
unlockedAchievementCount: game.unlockedAchievementCount,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await gamesSublevel.put(gameKey, {
|
await gamesSublevel.put(gameKey, {
|
||||||
@@ -55,6 +59,8 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
favorite: game.isFavorite ?? false,
|
favorite: game.isFavorite ?? false,
|
||||||
isPinned: game.isPinned ?? false,
|
isPinned: game.isPinned ?? false,
|
||||||
|
achievementCount: game.achievementCount,
|
||||||
|
unlockedAchievementCount: game.unlockedAchievementCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ import { db, levelKeys, themesSublevel } from "@main/level";
|
|||||||
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
|
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
|
||||||
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 { LocalNotificationManager } from "./local-notifications";
|
||||||
|
|
||||||
|
const getStaticImage = async (path: string) => {
|
||||||
|
return processProfileImage(path, "jpg")
|
||||||
|
.then((response) => response.imagePath)
|
||||||
|
.catch(() => path);
|
||||||
|
};
|
||||||
|
|
||||||
async function downloadImage(url: string | null) {
|
async function downloadImage(url: string | null) {
|
||||||
if (!url) return undefined;
|
if (!url) return undefined;
|
||||||
@@ -31,8 +39,9 @@ async function downloadImage(url: string | null) {
|
|||||||
response.data.pipe(writer);
|
response.data.pipe(writer);
|
||||||
|
|
||||||
return new Promise<string | undefined>((resolve) => {
|
return new Promise<string | undefined>((resolve) => {
|
||||||
writer.on("finish", () => {
|
writer.on("finish", async () => {
|
||||||
resolve(outputPath);
|
const staticImagePath = await getStaticImage(outputPath);
|
||||||
|
resolve(staticImagePath);
|
||||||
});
|
});
|
||||||
writer.on("error", () => {
|
writer.on("error", () => {
|
||||||
logger.error("Failed to download image", { url });
|
logger.error("Failed to download image", { url });
|
||||||
@@ -70,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 (
|
||||||
@@ -173,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@ export class SystemPath {
|
|||||||
};
|
};
|
||||||
|
|
||||||
static checkIfPathsAreAvailable() {
|
static checkIfPathsAreAvailable() {
|
||||||
const paths = Object.keys(SystemPath.paths) as Array<
|
const paths = Object.keys(
|
||||||
keyof typeof SystemPath.paths
|
SystemPath.paths
|
||||||
>;
|
) as (keyof typeof SystemPath.paths)[];
|
||||||
|
|
||||||
paths.forEach((pathName) => {
|
paths.forEach((pathName) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -138,7 +138,8 @@ export class WindowManager {
|
|||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
if (
|
if (
|
||||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||||
details.url.includes("chatwoot")
|
details.url.includes("chatwoot") ||
|
||||||
|
details.url.includes("workwonders")
|
||||||
) {
|
) {
|
||||||
return callback(details);
|
return callback(details);
|
||||||
}
|
}
|
||||||
@@ -159,7 +160,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 +224,7 @@ export class WindowManager {
|
|||||||
? {
|
? {
|
||||||
x: undefined,
|
x: undefined,
|
||||||
y: undefined,
|
y: undefined,
|
||||||
height: this.initialConfigInitializationMainWindow.height ?? 720,
|
height: this.initialConfigInitializationMainWindow.height ?? 860,
|
||||||
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
||||||
isMaximized: true,
|
isMaximized: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => {
|
|||||||
friendRequestCount: payload.friendRequestCount,
|
friendRequestCount: payload.friendRequestCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await HydraApi.get(`/users/${payload.senderId}`);
|
if (payload.senderId) {
|
||||||
|
const user = await HydraApi.get(`/users/${payload.senderId}`);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
publishNewFriendRequestNotification(user);
|
publishNewFriendRequestNotification(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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"));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
GameAchievement,
|
GameAchievement,
|
||||||
Theme,
|
Theme,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
ShortcutLocation,
|
ShortcutLocation,
|
||||||
AchievementCustomNotificationPosition,
|
AchievementCustomNotificationPosition,
|
||||||
AchievementNotificationInfo,
|
AchievementNotificationInfo,
|
||||||
@@ -267,6 +268,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 +498,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 +507,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 +559,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,
|
||||||
@@ -619,4 +671,28 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
},
|
},
|
||||||
closeEditorWindow: (themeId?: string) =>
|
closeEditorWindow: (themeId?: string) =>
|
||||||
ipcRenderer.invoke("closeEditorWindow", themeId),
|
ipcRenderer.invoke("closeEditorWindow", themeId),
|
||||||
|
|
||||||
|
/* LevelDB Generic CRUD */
|
||||||
|
leveldb: {
|
||||||
|
get: (
|
||||||
|
key: string,
|
||||||
|
sublevelName?: string | null,
|
||||||
|
valueEncoding?: "json" | "utf8"
|
||||||
|
) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding),
|
||||||
|
put: (
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
sublevelName?: string | null,
|
||||||
|
valueEncoding?: "json" | "utf8"
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding),
|
||||||
|
del: (key: string, sublevelName?: string | null) =>
|
||||||
|
ipcRenderer.invoke("leveldbDel", key, sublevelName),
|
||||||
|
clear: (sublevelName: string) =>
|
||||||
|
ipcRenderer.invoke("leveldbClear", sublevelName),
|
||||||
|
values: (sublevelName: string) =>
|
||||||
|
ipcRenderer.invoke("leveldbValues", sublevelName),
|
||||||
|
iterator: (sublevelName: string) =>
|
||||||
|
ipcRenderer.invoke("leveldbIterator", sublevelName),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
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 {
|
import {
|
||||||
@@ -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,
|
||||||
@@ -31,6 +33,8 @@ import {
|
|||||||
getAchievementSoundUrl,
|
getAchievementSoundUrl,
|
||||||
getAchievementSoundVolume,
|
getAchievementSoundVolume,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
import { levelDBService } from "./services/leveldb.service";
|
||||||
|
import type { UserPreferences } from "@types";
|
||||||
import "./app.scss";
|
import "./app.scss";
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
@@ -49,12 +53,7 @@ export function App() {
|
|||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userDetails,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
isFriendsModalVisible,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
friendModalUserId,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
updateUserDetails,
|
updateUserDetails,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
@@ -76,12 +75,17 @@ export function App() {
|
|||||||
|
|
||||||
const { showSuccessToast } = useToast();
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
|
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
|
||||||
|
useState(false);
|
||||||
|
const [archivePaths, setArchivePaths] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
|
Promise.all([
|
||||||
([preferences]) => {
|
levelDBService.get("userPreferences", null, "json"),
|
||||||
dispatch(setUserPreferences(preferences));
|
updateLibrary(),
|
||||||
}
|
]).then(([preferences]) => {
|
||||||
);
|
dispatch(setUserPreferences(preferences as UserPreferences | null));
|
||||||
|
});
|
||||||
}, [navigate, location.pathname, dispatch, updateLibrary]);
|
}, [navigate, location.pathname, dispatch, updateLibrary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,7 +129,6 @@ export function App() {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
updateUserDetails(response);
|
updateUserDetails(response);
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -142,7 +145,6 @@ export function App() {
|
|||||||
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"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,12 +183,23 @@ 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;
|
||||||
@@ -204,7 +217,11 @@ export function App() {
|
|||||||
}, [dispatch, draggingDisabled]);
|
}, [dispatch, draggingDisabled]);
|
||||||
|
|
||||||
const loadAndApplyTheme = useCallback(async () => {
|
const loadAndApplyTheme = useCallback(async () => {
|
||||||
const activeTheme = await window.electron.getActiveCustomTheme();
|
const allThemes = (await levelDBService.values("themes")) as {
|
||||||
|
isActive?: boolean;
|
||||||
|
code?: string;
|
||||||
|
}[];
|
||||||
|
const activeTheme = allThemes.find((theme) => theme.isActive);
|
||||||
if (activeTheme?.code) {
|
if (activeTheme?.code) {
|
||||||
injectCustomCss(activeTheme.code);
|
injectCustomCss(activeTheme.code);
|
||||||
} else {
|
} else {
|
||||||
@@ -274,14 +291,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||||||
>
|
>
|
||||||
<div className="game-card__backdrop">
|
<div className="game-card__backdrop">
|
||||||
<img
|
<img
|
||||||
src={game.libraryImageUrl}
|
src={game.libraryImageUrl ?? undefined}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className="game-card__cover"
|
className="game-card__cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
import {
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
useSearchHistory,
|
||||||
|
useSearchSuggestions,
|
||||||
|
} from "@renderer/hooks";
|
||||||
|
|
||||||
import "./header.scss";
|
import "./header.scss";
|
||||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
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 { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
const pathTitle: Record<string, string> = {
|
const pathTitle: Record<string, string> = {
|
||||||
"/": "home",
|
"/": "home",
|
||||||
@@ -20,6 +28,7 @@ const pathTitle: Record<string, string> = {
|
|||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -37,6 +46,7 @@ export function Header() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isOnLibraryPage = location.pathname.startsWith("/library");
|
const isOnLibraryPage = location.pathname.startsWith("/library");
|
||||||
|
const isOnCataloguePage = location.pathname.startsWith("/catalogue");
|
||||||
|
|
||||||
const searchValue = isOnLibraryPage
|
const searchValue = isOnLibraryPage
|
||||||
? librarySearchValue
|
? librarySearchValue
|
||||||
@@ -45,13 +55,34 @@ export function Header() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const { t } = useTranslation("header");
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
|
const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } =
|
||||||
|
useSearchHistory();
|
||||||
|
|
||||||
|
const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions(
|
||||||
|
searchValue,
|
||||||
|
isOnLibraryPage,
|
||||||
|
isDropdownVisible && isFocused && !isOnCataloguePage
|
||||||
|
);
|
||||||
|
|
||||||
|
const historyItems = getRecentHistory(
|
||||||
|
isOnLibraryPage ? "library" : "catalogue",
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
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");
|
||||||
@@ -59,13 +90,43 @@ export function Header() {
|
|||||||
return t(pathTitle[location.pathname]);
|
return t(pathTitle[location.pathname]);
|
||||||
}, [location.pathname, headerTitle, t]);
|
}, [location.pathname, headerTitle, t]);
|
||||||
|
|
||||||
|
const totalItems = historyItems.length + suggestions.length;
|
||||||
|
|
||||||
|
const updateDropdownPosition = () => {
|
||||||
|
if (searchContainerRef.current) {
|
||||||
|
const rect = searchContainerRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.bottom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (isFocused && isDropdownVisible) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFocused(true);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
setTimeout(() => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsDropdownVisible(true);
|
||||||
|
}, 220);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setIsFocused(false);
|
setTimeout(() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
setIsDropdownVisible(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackButtonClick = () => {
|
const handleBackButtonClick = () => {
|
||||||
@@ -77,10 +138,37 @@ export function Header() {
|
|||||||
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
|
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
|
||||||
} else {
|
} else {
|
||||||
dispatch(setFilters({ title: value.slice(0, 255) }));
|
dispatch(setFilters({ title: value.slice(0, 255) }));
|
||||||
if (!location.pathname.startsWith("/catalogue")) {
|
|
||||||
navigate("/catalogue");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setActiveIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeSearch = (query: string) => {
|
||||||
|
const context = isOnLibraryPage ? "library" : "catalogue";
|
||||||
|
if (query.trim()) {
|
||||||
|
addToHistory(query, context);
|
||||||
|
}
|
||||||
|
handleSearch(query);
|
||||||
|
|
||||||
|
if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) {
|
||||||
|
navigate("/catalogue");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDropdownVisible(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectHistory = (query: string) => {
|
||||||
|
executeSearch(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSuggestion = (suggestion: {
|
||||||
|
title: string;
|
||||||
|
objectId: string;
|
||||||
|
shop: GameShop;
|
||||||
|
}) => {
|
||||||
|
setIsDropdownVisible(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
navigate(buildGameDetailsPath(suggestion));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
@@ -89,14 +177,79 @@ export function Header() {
|
|||||||
} else {
|
} else {
|
||||||
dispatch(setFilters({ title: "" }));
|
dispatch(setFilters({ title: "" }));
|
||||||
}
|
}
|
||||||
|
setActiveIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveHistoryItem = (query: string) => {
|
||||||
|
removeFromHistory(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearHistory = () => {
|
||||||
|
clearHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (activeIndex >= 0 && activeIndex < totalItems) {
|
||||||
|
if (activeIndex < historyItems.length) {
|
||||||
|
handleSelectHistory(historyItems[activeIndex].query);
|
||||||
|
} else {
|
||||||
|
const suggestionIndex = activeIndex - historyItems.length;
|
||||||
|
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||||
|
}
|
||||||
|
} else if (searchValue.trim()) {
|
||||||
|
executeSearch(searchValue);
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||||
|
if (!isDropdownVisible) {
|
||||||
|
setIsDropdownVisible(true);
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((prev) => (prev > -1 ? prev - 1 : -1));
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDropdownVisible(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDropdown = () => {
|
||||||
|
setIsDropdownVisible(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) {
|
const prevPath = sessionStorage.getItem("prevPath");
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevPath?.startsWith("/catalogue") &&
|
||||||
|
!currentPath.startsWith("/catalogue") &&
|
||||||
|
catalogueSearchValue
|
||||||
|
) {
|
||||||
dispatch(setFilters({ title: "" }));
|
dispatch(setFilters({ title: "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem("prevPath", currentPath);
|
||||||
}, [location.pathname, catalogueSearchValue, dispatch]);
|
}, [location.pathname, catalogueSearchValue, dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDropdownVisible) return;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [isDropdownVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
@@ -128,6 +281,7 @@ export function Header() {
|
|||||||
|
|
||||||
<section className="header__section">
|
<section className="header__section">
|
||||||
<div
|
<div
|
||||||
|
ref={searchContainerRef}
|
||||||
className={cn("header__search", {
|
className={cn("header__search", {
|
||||||
"header__search--focused": isFocused,
|
"header__search--focused": isFocused,
|
||||||
})}
|
})}
|
||||||
@@ -148,8 +302,9 @@ export function Header() {
|
|||||||
value={searchValue}
|
value={searchValue}
|
||||||
className="header__search-input"
|
className="header__search-input"
|
||||||
onChange={(event) => handleSearch(event.target.value)}
|
onChange={(event) => handleSearch(event.target.value)}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{searchValue && (
|
{searchValue && (
|
||||||
@@ -165,6 +320,28 @@ export function Header() {
|
|||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
<AutoUpdateSubHeader />
|
<AutoUpdateSubHeader />
|
||||||
|
|
||||||
|
<SearchDropdown
|
||||||
|
visible={
|
||||||
|
isDropdownVisible &&
|
||||||
|
(searchValue.trim().length > 0 ||
|
||||||
|
historyItems.length > 0 ||
|
||||||
|
suggestions.length > 0 ||
|
||||||
|
isLoadingSuggestions)
|
||||||
|
}
|
||||||
|
position={dropdownPosition}
|
||||||
|
historyItems={historyItems}
|
||||||
|
suggestions={suggestions}
|
||||||
|
isLoadingSuggestions={isLoadingSuggestions}
|
||||||
|
onSelectHistory={handleSelectHistory}
|
||||||
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
|
onRemoveHistoryItem={handleRemoveHistoryItem}
|
||||||
|
onClearHistory={handleClearHistory}
|
||||||
|
onClose={handleCloseDropdown}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
currentQuery={searchValue}
|
||||||
|
searchContainerRef={searchContainerRef}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ export function Hero() {
|
|||||||
>
|
>
|
||||||
<div className="hero__backdrop">
|
<div className="hero__backdrop">
|
||||||
<img
|
<img
|
||||||
src={game.libraryHeroImageUrl}
|
src={game.libraryHeroImageUrl ?? undefined}
|
||||||
alt={game.description ?? ""}
|
alt={game.description ?? ""}
|
||||||
className="hero__media"
|
className="hero__media"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<img
|
<img
|
||||||
src={game.logoImageUrl}
|
src={game.logoImageUrl ?? undefined}
|
||||||
width="250px"
|
width="250px"
|
||||||
alt={game.description ?? ""}
|
alt={game.description ?? ""}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ export * from "./context-menu/context-menu";
|
|||||||
export * from "./game-context-menu/game-context-menu";
|
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 "./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
|
||||||
|
|||||||
107
src/renderer/src/components/search-dropdown/highlight-text.tsx
Normal file
107
src/renderer/src/components/search-dropdown/highlight-text.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface HighlightTextProps {
|
||||||
|
readonly text: string;
|
||||||
|
readonly query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return <>{text}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryWords = query
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => word.length > 0);
|
||||||
|
|
||||||
|
if (queryWords.length === 0) {
|
||||||
|
return <>{text}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: { start: number; end: number }[] = [];
|
||||||
|
const textLower = text.toLowerCase();
|
||||||
|
|
||||||
|
queryWords.forEach((queryWord) => {
|
||||||
|
const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = new RegExp(
|
||||||
|
`(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`,
|
||||||
|
"gi"
|
||||||
|
);
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(textLower)) !== null) {
|
||||||
|
const matchedText = match[0];
|
||||||
|
const leadingSpace = matchedText.startsWith(" ") ? 1 : 0;
|
||||||
|
const start = match.index + leadingSpace;
|
||||||
|
const end = start + queryWord.length;
|
||||||
|
|
||||||
|
matches.push({ start, end });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return <>{text}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
const mergedMatches: { start: number; end: number }[] = [];
|
||||||
|
let current = matches[0];
|
||||||
|
|
||||||
|
for (let i = 1; i < matches.length; i++) {
|
||||||
|
if (matches[i].start <= current.end) {
|
||||||
|
current = {
|
||||||
|
start: current.start,
|
||||||
|
end: Math.max(current.end, matches[i].end),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
mergedMatches.push(current);
|
||||||
|
current = matches[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergedMatches.push(current);
|
||||||
|
|
||||||
|
const parts: { text: string; highlight: boolean; key: string }[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
mergedMatches.forEach((match) => {
|
||||||
|
if (match.start > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(lastIndex, match.start),
|
||||||
|
highlight: false,
|
||||||
|
key: `${lastIndex}-${match.start}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(match.start, match.end),
|
||||||
|
highlight: true,
|
||||||
|
key: `${match.start}-${match.end}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = match.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(lastIndex),
|
||||||
|
highlight: false,
|
||||||
|
key: `${lastIndex}-${text.length}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part) =>
|
||||||
|
part.highlight ? (
|
||||||
|
<mark key={part.key} className="search-dropdown__highlight">
|
||||||
|
{part.text}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={part.key}>{part.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/renderer/src/components/search-dropdown/search-dropdown.scss
Normal file
153
src/renderer/src/components/search-dropdown/search-dropdown.scss
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.search-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
background-color: globals.$dark-background-color;
|
||||||
|
border: 1px solid globals.$border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 250px;
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid globals.$border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear-text-button {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: color ease 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover .search-dropdown__item-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-remove {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: globals.$muted-color;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity ease 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff3333;
|
||||||
|
background-color: rgba(255, 85, 85, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
color: #dadbe1;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&--active {
|
||||||
|
background-color: globals.$background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
|
||||||
|
&--image {
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__empty {
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__highlight {
|
||||||
|
background-color: rgba(255, 193, 7, 0.4);
|
||||||
|
color: #ffa000;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/renderer/src/components/search-dropdown/search-dropdown.tsx
Normal file
219
src/renderer/src/components/search-dropdown/search-dropdown.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
|
import cn from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
|
||||||
|
import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions";
|
||||||
|
import { HighlightText } from "./highlight-text";
|
||||||
|
import "./search-dropdown.scss";
|
||||||
|
|
||||||
|
export interface SearchDropdownProps {
|
||||||
|
visible: boolean;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
historyItems: SearchHistoryEntry[];
|
||||||
|
suggestions: SearchSuggestion[];
|
||||||
|
isLoadingSuggestions: boolean;
|
||||||
|
onSelectHistory: (query: string) => void;
|
||||||
|
onSelectSuggestion: (suggestion: SearchSuggestion) => void;
|
||||||
|
onRemoveHistoryItem: (query: string) => void;
|
||||||
|
onClearHistory: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
activeIndex: number;
|
||||||
|
currentQuery: string;
|
||||||
|
searchContainerRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchDropdown({
|
||||||
|
visible,
|
||||||
|
position,
|
||||||
|
historyItems,
|
||||||
|
suggestions,
|
||||||
|
isLoadingSuggestions,
|
||||||
|
onSelectHistory,
|
||||||
|
onSelectSuggestion,
|
||||||
|
onRemoveHistoryItem,
|
||||||
|
onClearHistory,
|
||||||
|
onClose,
|
||||||
|
activeIndex,
|
||||||
|
currentQuery,
|
||||||
|
searchContainerRef,
|
||||||
|
}: SearchDropdownProps) {
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||||
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setAdjustedPosition(position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPosition = () => {
|
||||||
|
if (!dropdownRef.current) return;
|
||||||
|
|
||||||
|
const rect = dropdownRef.current.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let adjustedX = position.x;
|
||||||
|
let adjustedY = position.y;
|
||||||
|
|
||||||
|
if (adjustedX + 250 > viewportWidth - 10) {
|
||||||
|
adjustedX = Math.max(10, viewportWidth - 250 - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustedY + rect.height > viewportHeight - 10) {
|
||||||
|
adjustedY = Math.max(10, viewportHeight - rect.height - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdjustedPosition({ x: adjustedX, y: adjustedY });
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(checkPosition);
|
||||||
|
}, [visible, position]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(target) &&
|
||||||
|
!searchContainerRef?.current?.contains(target)
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [visible, onClose, searchContainerRef]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const hasHistory = historyItems.length > 0;
|
||||||
|
const hasSuggestions = suggestions.length > 0;
|
||||||
|
|
||||||
|
const getItemIndex = (
|
||||||
|
section: "history" | "suggestion",
|
||||||
|
indexInSection: number
|
||||||
|
) => {
|
||||||
|
if (section === "history") {
|
||||||
|
return indexInSection;
|
||||||
|
}
|
||||||
|
return historyItems.length + indexInSection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="search-dropdown"
|
||||||
|
style={{
|
||||||
|
left: adjustedPosition.x,
|
||||||
|
top: adjustedPosition.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasHistory && (
|
||||||
|
<div className="search-dropdown__section">
|
||||||
|
<div className="search-dropdown__section-header">
|
||||||
|
<span className="search-dropdown__section-title">
|
||||||
|
{t("recent_searches")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-dropdown__clear-text-button"
|
||||||
|
onClick={onClearHistory}
|
||||||
|
>
|
||||||
|
{t("clear_history")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="search-dropdown__list">
|
||||||
|
{historyItems.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={`history-${item.query}-${item.timestamp}`}
|
||||||
|
className="search-dropdown__item-container"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("search-dropdown__item", {
|
||||||
|
"search-dropdown__item--active":
|
||||||
|
activeIndex === getItemIndex("history", index),
|
||||||
|
})}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelectHistory(item.query)}
|
||||||
|
>
|
||||||
|
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
||||||
|
<span className="search-dropdown__item-text">
|
||||||
|
{item.query}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-dropdown__item-remove"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveHistoryItem(item.query);
|
||||||
|
}}
|
||||||
|
title={t("remove_from_history")}
|
||||||
|
>
|
||||||
|
<XIcon size={12} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSuggestions && (
|
||||||
|
<div className="search-dropdown__section">
|
||||||
|
<div className="search-dropdown__section-header">
|
||||||
|
<span className="search-dropdown__section-title">
|
||||||
|
{t("suggestions")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="search-dropdown__list">
|
||||||
|
{suggestions.map((item, index) => (
|
||||||
|
<li key={`suggestion-${item.objectId}-${item.shop}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("search-dropdown__item", {
|
||||||
|
"search-dropdown__item--active":
|
||||||
|
activeIndex === getItemIndex("suggestion", index),
|
||||||
|
})}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelectSuggestion(item)}
|
||||||
|
>
|
||||||
|
{item.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="search-dropdown__item-icon search-dropdown__item-icon--image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SearchIcon
|
||||||
|
size={16}
|
||||||
|
className="search-dropdown__item-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="search-dropdown__item-text">
|
||||||
|
<HighlightText text={item.title} query={currentQuery} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
||||||
|
<div className="search-dropdown__loading">{t("loading")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(dropdownContent, document.body);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,6 @@
|
|||||||
border: 1px solid rgba(34, 197, 94, 0.5);
|
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
&__section-header {
|
&__section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user