mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
166 Commits
feat/resiz
...
v3.7.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
aebf6d1cae | ||
|
|
a2148dd1ef | ||
|
|
8dc5be1bdf | ||
|
|
133168c6c7 | ||
|
|
d59b96f446 | ||
|
|
a1eef4eab6 | ||
|
|
25103e5eb7 | ||
|
|
9cf0ef4b62 | ||
|
|
1521d7c058 | ||
|
|
14eb0f8172 | ||
|
|
860030a510 | ||
|
|
f0e4d241f9 | ||
|
|
44b24ab63d | ||
|
|
7c1adb70ea | ||
|
|
9854ed2f53 | ||
|
|
b8647a3300 | ||
|
|
95894484f1 | ||
|
|
6fc5a70722 | ||
|
|
399669a94c | ||
|
|
77b2fc3946 | ||
|
|
3472e90858 | ||
|
|
d80daa59d0 | ||
|
|
46df34e8a5 | ||
|
|
272b047ded | ||
|
|
dfba38aeed | ||
|
|
d54ff9a949 | ||
|
|
e272470a7b | ||
|
|
53bc3551e1 | ||
|
|
3daf28c882 | ||
|
|
e128dad4dd | ||
|
|
65e2bb38a0 | ||
|
|
5f09321728 | ||
|
|
011559b499 | ||
|
|
482d9b2f96 | ||
|
|
c890b0fd56 | ||
|
|
cf48627a8d | ||
|
|
196413ee28 | ||
|
|
c3a4990a50 | ||
|
|
fa4c11e458 | ||
|
|
50b0a82204 | ||
|
|
b6bbf05da6 | ||
|
|
154b6271a1 | ||
|
|
a6cbaf6dc1 | ||
|
|
6e6e0f7bb7 | ||
|
|
893802be55 | ||
|
|
3bef0c9269 | ||
|
|
754e9c14b8 | ||
|
|
5e653be4c3 | ||
|
|
cedf7e6e37 | ||
|
|
518a0e1cf4 | ||
|
|
5f56a3d517 | ||
|
|
2359c4cc5e | ||
|
|
66bb5221c1 | ||
|
|
088feaffc2 | ||
|
|
aa6b595b18 | ||
|
|
20338fa20b | ||
|
|
e7a437e839 | ||
|
|
b578af4612 | ||
|
|
6f6b7d49ac | ||
|
|
5c445f8a90 | ||
|
|
87d35da9fc | ||
|
|
5067cf163e | ||
|
|
efab242c74 | ||
|
|
4dd3c9de76 | ||
|
|
101bc35460 | ||
|
|
f5470b29c0 | ||
|
|
a0a967aacd | ||
|
|
e19102ea66 | ||
|
|
107b61f663 | ||
|
|
811a6ad955 | ||
|
|
6fb8bbf744 | ||
|
|
459017a4a6 | ||
|
|
d6ff8f670e | ||
|
|
33e0d50966 | ||
|
|
361073d3f8 | ||
|
|
d168e20385 |
20
.cursorrules
20
.cursorrules
@@ -28,6 +28,26 @@
|
||||
- Use async/await instead of promises when possible
|
||||
- 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
|
||||
|
||||
- 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):**
|
||||
|
||||
- [ ] 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 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.
|
||||
|
||||
**Fill in the PR content:**
|
||||
|
||||
-
|
||||
|
||||
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -2,6 +2,9 @@ name: Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
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
|
||||
echo "No changes to commit"
|
||||
else
|
||||
COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
|
||||
COMMIT_MSG="${{ steps.get-version.outputs.version }}"
|
||||
|
||||
git commit -m "$COMMIT_MSG"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.7.4",
|
||||
"version": "3.7.5",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -63,6 +63,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^20.5.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
"hls.js": "^1.5.12",
|
||||
"i18next": "^23.11.2",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"jsdom": "^24.0.0",
|
||||
@@ -84,7 +85,7 @@
|
||||
"sound-play": "^1.1.0",
|
||||
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tar": "^7.4.3",
|
||||
"tar": "^7.5.2",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"user-agents": "^1.1.387",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
@@ -153,8 +153,11 @@ def profile_image():
|
||||
data = request.get_json()
|
||||
image_path = data.get('image_path')
|
||||
|
||||
# use webp as default value for target_extension
|
||||
target_extension = data.get('target_extension') or 'webp'
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
@@ -4,7 +4,7 @@ import os, uuid, tempfile
|
||||
class ProfileImageProcessor:
|
||||
|
||||
@staticmethod
|
||||
def get_parsed_image_data(image_path):
|
||||
def get_parsed_image_data(image_path, target_extension):
|
||||
Image.MAX_IMAGE_PIXELS = 933120000
|
||||
|
||||
image = Image.open(image_path)
|
||||
@@ -16,7 +16,7 @@ class ProfileImageProcessor:
|
||||
return image_path, mime_type
|
||||
else:
|
||||
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)
|
||||
|
||||
new_image = Image.open(new_image_path)
|
||||
@@ -26,5 +26,5 @@ class ProfileImageProcessor:
|
||||
|
||||
|
||||
@staticmethod
|
||||
def process_image(image_path):
|
||||
return ProfileImageProcessor.get_parsed_image_data(image_path)
|
||||
def process_image(image_path, target_extension):
|
||||
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catalogue",
|
||||
"library": "Library",
|
||||
"downloads": "Downloads",
|
||||
"settings": "Settings",
|
||||
"my_library": "My library",
|
||||
@@ -92,8 +93,16 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Search games",
|
||||
"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",
|
||||
"catalogue": "Catalogue",
|
||||
"library": "Library",
|
||||
"downloads": "Downloads",
|
||||
"search_results": "Search results",
|
||||
"settings": "Settings",
|
||||
@@ -194,6 +203,7 @@
|
||||
"download_in_progress": "Download in progress",
|
||||
"download_paused": "Download paused",
|
||||
"last_downloaded_option": "Last downloaded option",
|
||||
"new_download_option": "New",
|
||||
"create_steam_shortcut": "Create Steam shortcut",
|
||||
"create_shortcut_success": "Shortcut created successfully",
|
||||
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
|
||||
@@ -555,6 +565,15 @@
|
||||
"platinum": "Platinum",
|
||||
"hidden": "Hidden",
|
||||
"test_notification": "Test notification",
|
||||
"achievement_sound_volume": "Achievement sound volume",
|
||||
"select_achievement_sound": "Select achievement sound",
|
||||
"change_achievement_sound": "Change achievement sound",
|
||||
"remove_achievement_sound": "Remove achievement sound",
|
||||
"preview_sound": "Preview sound",
|
||||
"select": "Select",
|
||||
"preview": "Preview",
|
||||
"remove": "Remove",
|
||||
"no_sound_file_selected": "No sound file selected",
|
||||
"notification_preview": "Achievement Notification Preview",
|
||||
"enable_friend_start_game_notifications": "When a friend starts playing a game",
|
||||
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
|
||||
@@ -698,6 +717,27 @@
|
||||
"delete_review": "Delete Review",
|
||||
"loading_reviews": "Loading reviews..."
|
||||
},
|
||||
"library": {
|
||||
"library": "Library",
|
||||
"play": "Play",
|
||||
"download": "Download",
|
||||
"downloading": "Downloading",
|
||||
"game": "game",
|
||||
"games": "games",
|
||||
"grid_view": "Grid view",
|
||||
"compact_view": "Compact view",
|
||||
"large_view": "Large view",
|
||||
"no_games_title": "Your library is empty",
|
||||
"no_games_description": "Add games from the catalogue or download them to get started",
|
||||
"amount_hours": "{{amount}} hours",
|
||||
"amount_minutes": "{{amount}} minutes",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||
"all_games": "All Games",
|
||||
"recently_played": "Recently Played",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement unlocked",
|
||||
"user_achievements": "{{displayName}}'s Achievements",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catálogo",
|
||||
"library": "Librería",
|
||||
"downloads": "Descargas",
|
||||
"settings": "Ajustes",
|
||||
"my_library": "Mi Librería",
|
||||
@@ -92,8 +93,16 @@
|
||||
},
|
||||
"header": {
|
||||
"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",
|
||||
"catalogue": "Catálogo",
|
||||
"library": "Librería",
|
||||
"downloads": "Descargas",
|
||||
"search_results": "Resultados de búsqueda",
|
||||
"settings": "Ajustes",
|
||||
@@ -192,6 +201,7 @@
|
||||
"download_in_progress": "Descarga en progreso",
|
||||
"download_paused": "Descarga pausada",
|
||||
"last_downloaded_option": "Última opción de descarga",
|
||||
"new_download_option": "Nuevo",
|
||||
"create_steam_shortcut": "Crear atajo de Steam",
|
||||
"create_shortcut_success": "Atajo creado con éxito",
|
||||
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
|
||||
@@ -542,6 +552,12 @@
|
||||
"platinum": "Platino",
|
||||
"hidden": "Oculto",
|
||||
"test_notification": "Probar notificación",
|
||||
"achievement_sound_volume": "Volumen del sonido de logro",
|
||||
"select_achievement_sound": "Seleccionar sonido de logro",
|
||||
"select": "Seleccionar",
|
||||
"preview": "Vista previa",
|
||||
"remove": "Remover",
|
||||
"no_sound_file_selected": "No se seleccionó ningún archivo de sonido",
|
||||
"notification_preview": "Probar notificación de logro",
|
||||
"debrid": "Debrid",
|
||||
"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.",
|
||||
@@ -716,5 +732,26 @@
|
||||
"hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!",
|
||||
"learn_more": "Descubrir más",
|
||||
"debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus"
|
||||
},
|
||||
"library": {
|
||||
"library": "Librería",
|
||||
"play": "Jugar",
|
||||
"download": "Descargar",
|
||||
"downloading": "Descargando",
|
||||
"game": "juego",
|
||||
"games": "juegos",
|
||||
"grid_view": "Vista de cuadrícula",
|
||||
"compact_view": "Vista compacta",
|
||||
"large_view": "Vista grande",
|
||||
"no_games_title": "Tu librería está vacía",
|
||||
"no_games_description": "Agregá juegos del catálogo o descargalos para comenzar",
|
||||
"amount_hours": "{{amount}} horas",
|
||||
"amount_minutes": "{{amount}} minutos",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente",
|
||||
"all_games": "Todos los Juegos",
|
||||
"recently_played": "Jugados Recientemente",
|
||||
"favorites": "Favoritos"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
"no_results": "Nincs találat",
|
||||
"start_typing": "Kereséshez gépelj...",
|
||||
"hot": "Most felkapott",
|
||||
"weekly": "📅 A hét felkapottjai",
|
||||
"weekly": "📅 Heti kiemeltek",
|
||||
"achievements": "🏆 Achievement támogatott"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Katalógus",
|
||||
"library": "Könyvtár",
|
||||
"downloads": "Letöltések",
|
||||
"settings": "Beállítások",
|
||||
"my_library": "Könyvtáram",
|
||||
@@ -81,7 +82,7 @@
|
||||
"update_decky_plugin": "Decky Plugin Frissítése",
|
||||
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
||||
"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_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
|
||||
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
|
||||
@@ -92,8 +93,10 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Keresés",
|
||||
"search_library": "Könyvtár böngészése",
|
||||
"home": "Főoldal",
|
||||
"catalogue": "Katalógus",
|
||||
"library": "Könyvtár",
|
||||
"downloads": "Letöltések",
|
||||
"search_results": "Keresési találatok",
|
||||
"settings": "Beállítások",
|
||||
@@ -117,7 +120,7 @@
|
||||
"tags": "Címkék",
|
||||
"publishers": "Kiadók",
|
||||
"download_sources": "Letöltési források",
|
||||
"result_count": "{{resultCount}} találatok",
|
||||
"result_count": "{{resultCount}} találat",
|
||||
"filter_count": "{{filterCount}} elérhető",
|
||||
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
|
||||
},
|
||||
@@ -166,11 +169,11 @@
|
||||
"download_now": "Letöltés",
|
||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||
"download_options": "Letöltési opciók",
|
||||
"download_path": "Letöltis hely",
|
||||
"download_path": "Letöltési hely",
|
||||
"previous_screenshot": "Előző screenshot",
|
||||
"next_screenshot": "Következő screenshot",
|
||||
"screenshot": "Screenshot {{number}}",
|
||||
"open_screenshot": "Screenshot megnyitása {{number}}",
|
||||
"open_screenshot": "{{number}} Screenshot megnyitása ",
|
||||
"download_settings": "Letöltési beállítások",
|
||||
"downloader": "Letöltési mód",
|
||||
"select_executable": "Tallózás",
|
||||
@@ -194,6 +197,7 @@
|
||||
"download_in_progress": "Letöltés folyamatban",
|
||||
"download_paused": "Letöltés szüneteltetve",
|
||||
"last_downloaded_option": "Utoljára letöltött",
|
||||
"new_download_option": "Új",
|
||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||
"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.",
|
||||
@@ -223,6 +227,7 @@
|
||||
"show_more": "Mutass többet",
|
||||
"show_less": "Mutass kevesebbet",
|
||||
"reviews": "Vélemények",
|
||||
"review_played_for": "Játszva",
|
||||
"leave_a_review": "Hagyd itt a véleményed",
|
||||
"write_review_placeholder": "Oszd meg gondolatod a játékról...",
|
||||
"sort_newest": "Legújabb",
|
||||
@@ -361,7 +366,10 @@
|
||||
"show_original": "Eredeti megjelenítése",
|
||||
"show_translation": "Fordítás megjelenítése",
|
||||
"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": {
|
||||
"title": "Hydra Aktiválása",
|
||||
@@ -488,11 +496,11 @@
|
||||
"no_email_account": "Még nincs beállított emailed",
|
||||
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
|
||||
"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",
|
||||
"become_subscriber": "Légy Hydra Cloud tag",
|
||||
"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",
|
||||
"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",
|
||||
@@ -551,10 +559,19 @@
|
||||
"platinum": "Platina",
|
||||
"hidden": "Rejtett",
|
||||
"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",
|
||||
"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",
|
||||
"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": {
|
||||
"download_complete": "Letöltés befejezve",
|
||||
@@ -670,7 +687,7 @@
|
||||
"report_reason_other": "Egyéb",
|
||||
"profile_reported": "Profil bejelentve",
|
||||
"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…",
|
||||
"background_image_updated": "Borítókép frissítve",
|
||||
"stats": "Statisztikák",
|
||||
@@ -689,7 +706,31 @@
|
||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Pozitív értékelésekkel szerzett pontok"
|
||||
"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_unlocked": "Achievement feloldva",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catálogo",
|
||||
"library": "Biblioteca",
|
||||
"downloads": "Downloads",
|
||||
"settings": "Ajustes",
|
||||
"my_library": "Biblioteca",
|
||||
@@ -92,11 +93,19 @@
|
||||
},
|
||||
"header": {
|
||||
"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",
|
||||
"library": "Biblioteca",
|
||||
"downloads": "Downloads",
|
||||
"search_results": "Resultados da busca",
|
||||
"settings": "Ajustes",
|
||||
"home": "Início",
|
||||
"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."
|
||||
},
|
||||
@@ -182,6 +191,7 @@
|
||||
"download_in_progress": "Download em andamento",
|
||||
"download_paused": "Download pausado",
|
||||
"last_downloaded_option": "Última opção baixada",
|
||||
"new_download_option": "Novo",
|
||||
"create_steam_shortcut": "Criar atalho na Steam",
|
||||
"create_shortcut_success": "Atalho criado com sucesso",
|
||||
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações",
|
||||
@@ -541,6 +551,12 @@
|
||||
"platinum": "Platina",
|
||||
"hidden": "Oculta",
|
||||
"test_notification": "Testar notificação",
|
||||
"achievement_sound_volume": "Volume do som de conquista",
|
||||
"select_achievement_sound": "Selecionar som de conquista",
|
||||
"select": "Selecionar",
|
||||
"preview": "Reproduzir",
|
||||
"remove": "Remover",
|
||||
"no_sound_file_selected": "Nenhum arquivo de som selecionado",
|
||||
"notification_preview": "Prévia da Notificação de Conquistas",
|
||||
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
|
||||
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
|
||||
@@ -731,5 +747,26 @@
|
||||
"hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!",
|
||||
"learn_more": "Saiba mais",
|
||||
"debrid_description": "Baixe até 4x mais rápido com Nimbus"
|
||||
},
|
||||
"library": {
|
||||
"library": "Biblioteca",
|
||||
"play": "Jogar",
|
||||
"download": "Baixar",
|
||||
"downloading": "Baixando",
|
||||
"game": "jogo",
|
||||
"games": "jogos",
|
||||
"grid_view": "Visualização em grade",
|
||||
"compact_view": "Visualização compacta",
|
||||
"large_view": "Visualização grande",
|
||||
"no_games_title": "Sua biblioteca está vazia",
|
||||
"no_games_description": "Adicione jogos do catálogo ou baixe-os para começar",
|
||||
"amount_hours": "{{amount}} horas",
|
||||
"amount_minutes": "{{amount}} minutos",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
|
||||
"all_games": "Todos os Jogos",
|
||||
"recently_played": "Jogados Recentemente",
|
||||
"favorites": "Favoritos"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,19 @@
|
||||
},
|
||||
"header": {
|
||||
"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",
|
||||
"library": "Biblioteca",
|
||||
"downloads": "Transferências",
|
||||
"search_results": "Resultados da pesquisa",
|
||||
"settings": "Definições",
|
||||
"home": "Início",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Каталог",
|
||||
"library": "Библиотека",
|
||||
"downloads": "Загрузки",
|
||||
"settings": "Настройки",
|
||||
"my_library": "Библиотека",
|
||||
@@ -92,8 +93,16 @@
|
||||
},
|
||||
"header": {
|
||||
"search": "Поиск",
|
||||
"search_library": "Поиск в библиотеке",
|
||||
"recent_searches": "Недавние поиски",
|
||||
"suggestions": "Предложения",
|
||||
"clear_history": "Очистить",
|
||||
"remove_from_history": "Удалить из истории",
|
||||
"loading": "Загрузка...",
|
||||
"no_results": "Нет результатов",
|
||||
"home": "Главная",
|
||||
"catalogue": "Каталог",
|
||||
"library": "Библиотека",
|
||||
"downloads": "Загрузки",
|
||||
"search_results": "Результаты поиска",
|
||||
"settings": "Настройки",
|
||||
@@ -194,6 +203,7 @@
|
||||
"download_in_progress": "Идёт загрузка",
|
||||
"download_paused": "Загрузка приостановлена",
|
||||
"last_downloaded_option": "Последний вариант загрузки",
|
||||
"new_download_option": "Новый",
|
||||
"create_steam_shortcut": "Создать ярлык Steam",
|
||||
"create_shortcut_success": "Ярлык создан",
|
||||
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
|
||||
@@ -555,6 +565,12 @@
|
||||
"platinum": "Платиновый",
|
||||
"hidden": "Скрытый",
|
||||
"test_notification": "Тестовое уведомление",
|
||||
"achievement_sound_volume": "Громкость звука достижения",
|
||||
"select_achievement_sound": "Выбрать звук достижения",
|
||||
"select": "Выбрать",
|
||||
"preview": "Предпросмотр",
|
||||
"remove": "Удалить",
|
||||
"no_sound_file_selected": "Файл звука не выбран",
|
||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
|
||||
@@ -727,5 +743,26 @@
|
||||
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
|
||||
"learn_more": "Подробнее",
|
||||
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
|
||||
},
|
||||
"library": {
|
||||
"library": "Библиотека",
|
||||
"play": "Играть",
|
||||
"download": "Скачать",
|
||||
"downloading": "Скачивание",
|
||||
"game": "игра",
|
||||
"games": "игры",
|
||||
"grid_view": "Вид сетки",
|
||||
"compact_view": "Компактный вид",
|
||||
"large_view": "Большой вид",
|
||||
"no_games_title": "Ваша библиотека пуста",
|
||||
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
|
||||
"amount_hours": "{{amount}} часов",
|
||||
"amount_minutes": "{{amount}} минут",
|
||||
"amount_hours_short": "{{amount}}ч",
|
||||
"amount_minutes_short": "{{amount}}м",
|
||||
"manual_playtime_tooltip": "Время игры было обновлено вручную",
|
||||
"all_games": "Все игры",
|
||||
"recently_played": "Недавно сыгранные",
|
||||
"favorites": "Избранное"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"downloads": "İndirilenler",
|
||||
"settings": "Ayarlar",
|
||||
"my_library": "Kütüphanem",
|
||||
"library": "Kütüphane",
|
||||
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
|
||||
"paused": "{{title}} (Duraklatıldı)",
|
||||
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
|
||||
@@ -26,7 +27,69 @@
|
||||
"sign_in": "Giriş Yap",
|
||||
"friends": "Arkadaşlar",
|
||||
"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": {
|
||||
"search": "Oyunlarda Ara",
|
||||
@@ -35,6 +98,8 @@
|
||||
"downloads": "İndirilenler",
|
||||
"search_results": "Arama Sonuçları",
|
||||
"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_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",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Hydra'yı Etkinleştir",
|
||||
@@ -379,7 +545,33 @@
|
||||
"hidden": "Gizli",
|
||||
"test_notification": "Test bildirimi",
|
||||
"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": {
|
||||
"download_complete": "İndirme tamamlandı",
|
||||
@@ -406,7 +598,8 @@
|
||||
"game_card": {
|
||||
"available_one": "Mevcut",
|
||||
"available_other": "Mevcut",
|
||||
"no_downloads": "İndirme mevcut değil"
|
||||
"no_downloads": "İndirme mevcut değil",
|
||||
"calculating": "Hesaplanıyor"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programlar Yüklü Değil",
|
||||
@@ -498,7 +691,46 @@
|
||||
"achievements_unlocked": "Açılan başarımlar",
|
||||
"earned_points": "Kazanılan puanlar",
|
||||
"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_unlocked": "Başarım açıldı",
|
||||
|
||||
@@ -27,7 +27,68 @@
|
||||
"friends": "好友",
|
||||
"favorites": "收藏",
|
||||
"need_help": "需要帮助?",
|
||||
"playable_button_title": "仅显示现在可以游玩的游戏"
|
||||
"playable_button_title": "仅显示现在可以游玩的游戏",
|
||||
"add_custom_game_tooltip": "添加自定义游戏",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"custom_game_modal": "添加自定义游戏",
|
||||
"custom_game_modal_add": "添加游戏",
|
||||
"custom_game_modal_adding": "正在添加游戏...",
|
||||
"custom_game_modal_browse": "浏览",
|
||||
"custom_game_modal_cancel": "取消",
|
||||
"custom_game_modal_description": "通过选择可执行文件将自定义游戏添加到您的库中",
|
||||
"custom_game_modal_enter_title": "输入标题",
|
||||
"custom_game_modal_executable": "可执行文件",
|
||||
"custom_game_modal_executable_path": "可执行文件路径",
|
||||
"custom_game_modal_failed": "添加自定义游戏失败",
|
||||
"custom_game_modal_select_executable": "选择可执行文件",
|
||||
"custom_game_modal_success": "自定义游戏添加成功",
|
||||
"custom_game_modal_title": "标题",
|
||||
"decky_plugin_installation_error": "安装 Decky 插件出错: {{error}}",
|
||||
"decky_plugin_installation_failed": "Decky 插件安装失败: {{error}}",
|
||||
"decky_plugin_installed": "Decky 插件 v{{version}} 安装成功",
|
||||
"decky_plugin_installed_version": "Decky 插件 (v{{version}})",
|
||||
"edit_game_modal": "自定义资源",
|
||||
"edit_game_modal_assets": "资源",
|
||||
"edit_game_modal_browse": "浏览",
|
||||
"edit_game_modal_cancel": "取消",
|
||||
"edit_game_modal_description": "自定义游戏资源和详情",
|
||||
"edit_game_modal_drop_hero_image_here": "拖放主图像到此处",
|
||||
"edit_game_modal_drop_icon_image_here": "拖放图标到此处",
|
||||
"edit_game_modal_drop_logo_image_here": "拖放Logo到此处",
|
||||
"edit_game_modal_drop_to_replace_hero": "拖放以替换主图像",
|
||||
"edit_game_modal_drop_to_replace_icon": "拖放以替换图标",
|
||||
"edit_game_modal_drop_to_replace_logo": "拖放以替换Logo",
|
||||
"edit_game_modal_enter_title": "输入标题",
|
||||
"edit_game_modal_failed": "资源更新失败",
|
||||
"edit_game_modal_fill_required": "请填写所有必填项",
|
||||
"edit_game_modal_hero": "库主图",
|
||||
"edit_game_modal_hero_preview": "库主图预览",
|
||||
"edit_game_modal_hero_resolution": "推荐分辨率: 1920x620px",
|
||||
"edit_game_modal_icon": "图标",
|
||||
"edit_game_modal_icon_preview": "图标预览",
|
||||
"edit_game_modal_icon_resolution": "推荐分辨率: 256x256px",
|
||||
"edit_game_modal_image": "图片",
|
||||
"edit_game_modal_image_filter": "图片",
|
||||
"edit_game_modal_image_preview": "图片预览",
|
||||
"edit_game_modal_logo": "Logo",
|
||||
"edit_game_modal_logo_preview": "Logo预览",
|
||||
"edit_game_modal_logo_resolution": "推荐分辨率: 640x360px",
|
||||
"edit_game_modal_select_hero": "选择库主图",
|
||||
"edit_game_modal_select_icon": "选择图标",
|
||||
"edit_game_modal_select_image": "选择图片",
|
||||
"edit_game_modal_select_logo": "选择Logo",
|
||||
"edit_game_modal_success": "资源更新成功",
|
||||
"edit_game_modal_title": "标题",
|
||||
"edit_game_modal_update": "更新",
|
||||
"edit_game_modal_updating": "正在更新...",
|
||||
"install_decky_plugin": "安装 Decky 插件",
|
||||
"install_decky_plugin_message": "这将下载并安装 Hydra 的 Decky Loader 插件。可能需要提升权限。继续吗?",
|
||||
"install_decky_plugin_title": "安装 Hydra Decky 插件",
|
||||
"show_playable_only_tooltip": "仅显示可游玩",
|
||||
"update_decky_plugin": "更新 Decky 插件",
|
||||
"update_decky_plugin_message": "有新版本的 Hydra Decky 插件可用。现在要更新吗?",
|
||||
"update_decky_plugin_title": "更新 Hydra Decky 插件"
|
||||
},
|
||||
"header": {
|
||||
"search": "搜索游戏",
|
||||
@@ -218,7 +279,93 @@
|
||||
"reset_achievements_title": "您确定吗?",
|
||||
"save_changes": "保存更改",
|
||||
"unfreeze_backup": "取消固定",
|
||||
"you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改"
|
||||
"you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改",
|
||||
"add_to_favorites": "添加到收藏",
|
||||
"already_in_library": "已在游戏库中",
|
||||
"audio": "音频",
|
||||
"backup_failed": "备份失败",
|
||||
"be_first_to_review": "成为第一个分享游戏感受的人!",
|
||||
"caption": "标题",
|
||||
"create_shortcut_simple": "创建快捷方式",
|
||||
"currency_country": "zh",
|
||||
"currency_symbol": "¥",
|
||||
"delete_review": "删除评价",
|
||||
"delete_review_modal_cancel_button": "取消",
|
||||
"delete_review_modal_delete_button": "删除",
|
||||
"delete_review_modal_description": "此操作无法撤销。",
|
||||
"delete_review_modal_title": "确定要删除您的评价吗?",
|
||||
"edit_game_modal_button": "自定义游戏资源",
|
||||
"failed_remove_files": "文件删除失败",
|
||||
"failed_remove_from_library": "移出游戏库失败",
|
||||
"failed_update_favorites": "收藏更新失败",
|
||||
"files_removed_success": "文件已成功删除",
|
||||
"filter_by_source": "按来源筛选",
|
||||
"game_added_to_pinned": "游戏已添加到置顶",
|
||||
"game_details": "游戏详情",
|
||||
"game_removed_from_library": "游戏已从库中移除",
|
||||
"game_removed_from_pinned": "游戏已从置顶移除",
|
||||
"hide": "隐藏",
|
||||
"hide_original": "隐藏原文",
|
||||
"historical_keyshop": "历史密钥商店",
|
||||
"historical_retail": "历史零售",
|
||||
"keyshop_price": "密钥商店价格",
|
||||
"language": "语言",
|
||||
"leave_a_review": "留下评价",
|
||||
"load_more_reviews": "加载更多评价",
|
||||
"loading_more_reviews": "正在加载更多评价...",
|
||||
"loading_reviews": "正在加载评价...",
|
||||
"manual_playtime_tooltip": "该游戏时长已手动更新",
|
||||
"manual_playtime_warning": "您的游戏时长将被标记为手动更新,且无法撤销。",
|
||||
"maybe_later": "以后再说",
|
||||
"no_prices_found": "未找到价格信息",
|
||||
"no_repacks_found": "未找到该游戏的下载来源",
|
||||
"no_reviews_yet": "暂无评价",
|
||||
"prices": "价格",
|
||||
"properties": "属性",
|
||||
"rating": "评分",
|
||||
"rating_count": "评分数",
|
||||
"rating_negative": "差评",
|
||||
"rating_neutral": "中性",
|
||||
"rating_positive": "好评",
|
||||
"rating_stats": "评分统计",
|
||||
"rating_very_negative": "极差",
|
||||
"rating_very_positive": "极好",
|
||||
"remove_from_favorites": "移出收藏",
|
||||
"remove_review": "移除评价",
|
||||
"retail_price": "零售价格",
|
||||
"review_cannot_be_empty": "评价内容不能为空。",
|
||||
"review_deleted_successfully": "评价已成功删除。",
|
||||
"review_deletion_failed": "评价删除失败,请重试。",
|
||||
"review_from_blocked_user": "来自被屏蔽用户的评价",
|
||||
"review_played_for": "已游玩",
|
||||
"review_submission_failed": "评价提交失败,请重试。",
|
||||
"review_submitted_successfully": "评价提交成功!",
|
||||
"reviews": "评价",
|
||||
"show": "显示",
|
||||
"show_less": "收起",
|
||||
"show_more": "展开",
|
||||
"show_original": "显示原文",
|
||||
"show_original_translated_from": "显示原文(由{{language}}翻译)",
|
||||
"show_translation": "显示翻译",
|
||||
"sort_highest_score": "最高分",
|
||||
"sort_lowest_score": "最低分",
|
||||
"sort_most_voted": "最多投票",
|
||||
"sort_newest": "最新",
|
||||
"sort_oldest": "最旧",
|
||||
"submit_review": "提交",
|
||||
"submitting": "正在提交...",
|
||||
"update_game_playtime": "更新游戏时长",
|
||||
"update_playtime": "更新时长",
|
||||
"update_playtime_description": "手动更新 {{game}} 的游玩时长",
|
||||
"update_playtime_error": "游戏时长更新失败",
|
||||
"update_playtime_success": "游戏时长已成功更新",
|
||||
"update_playtime_title": "更新游戏时长",
|
||||
"view_all_prices": "点击查看所有价格",
|
||||
"vote_failed": "投票失败,请重试。",
|
||||
"would_you_recommend_this_game": "您想为此游戏留下评价吗?",
|
||||
"write_review_placeholder": "分享您对本游戏的看法...",
|
||||
"yes": "是",
|
||||
"you_seemed_to_enjoy_this_game": "您似乎很喜欢这款游戏"
|
||||
},
|
||||
"activation": {
|
||||
"title": "激活 Hydra",
|
||||
@@ -394,7 +541,24 @@
|
||||
"update_email": "更新邮箱",
|
||||
"update_password": "更新密码",
|
||||
"variation": "变体",
|
||||
"web_store": "网络商店"
|
||||
"web_store": "网络商店",
|
||||
"adding": "添加中…",
|
||||
"autoplay_trailers_on_game_page": "在游戏页面自动播放预告片",
|
||||
"debrid": "Debrid下载服务",
|
||||
"debrid_description": "Debrid服务是一种高级不限速下载器,可让您以最快的网速下载托管在各类网盘上的文件,仅受您的网络速度限制。",
|
||||
"download_source_already_exists": "该下载源URL已存在。",
|
||||
"download_source_failed": "出错",
|
||||
"download_source_matched": "已更新",
|
||||
"download_source_matching": "正在更新",
|
||||
"download_source_no_information": "暂无信息",
|
||||
"download_source_pending_matching": "即将更新",
|
||||
"download_sources_synced_successfully": "所有下载源已同步",
|
||||
"enable_steam_achievements": "启用Steam成就搜索",
|
||||
"failed_add_download_source": "添加下载源失败,请重试。",
|
||||
"hide_to_tray_on_game_start": "启动游戏时隐藏到托盘",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"importing": "导入中…",
|
||||
"removed_all_download_sources": "已移除所有下载源"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "下载完成",
|
||||
@@ -421,7 +585,8 @@
|
||||
"game_card": {
|
||||
"no_downloads": "无可用下载选项",
|
||||
"available_one": "可用",
|
||||
"available_other": "可用"
|
||||
"available_other": "可用",
|
||||
"calculating": "正在计算"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "程序未安装",
|
||||
@@ -515,7 +680,23 @@
|
||||
"show_achievements_on_profile": "在您的个人资料上显示成就",
|
||||
"show_points_on_profile": "在您的个人资料上显示获得的积分",
|
||||
"stats": "统计",
|
||||
"top_percentile": "前 {{percentile}}%"
|
||||
"top_percentile": "前 {{percentile}}%",
|
||||
"achievements_earned": "已获得成就",
|
||||
"amount_hours_short": "{{amount}}小时",
|
||||
"amount_minutes_short": "{{amount}}分钟",
|
||||
"delete_review": "删除评价",
|
||||
"game_added_to_pinned": "游戏已添加到置顶",
|
||||
"game_removed_from_pinned": "游戏已从置顶移除",
|
||||
"karma": "业力",
|
||||
"karma_count": "业力值",
|
||||
"karma_description": "通过评论获得的点赞",
|
||||
"loading_reviews": "正在加载评价...",
|
||||
"manual_playtime_tooltip": "该游戏时长已手动更新",
|
||||
"pinned": "已置顶",
|
||||
"played_recently": "最近游玩",
|
||||
"playtime": "游戏时长",
|
||||
"sort_by": "排序方式:",
|
||||
"user_reviews": "用户评价"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "成就已解锁",
|
||||
|
||||
@@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
|
||||
|
||||
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
|
||||
|
||||
export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes");
|
||||
|
||||
export const MAIN_LOOP_INTERVAL = 2000;
|
||||
|
||||
export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15;
|
||||
|
||||
export const DECKY_PLUGINS_LOCATION = path.join(
|
||||
SystemPath.getPath("home"),
|
||||
"homebrew",
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getDownloadSourcesCheckBaseline } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDownloadSourcesCheckBaselineHandler = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
) => {
|
||||
return await getDownloadSourcesCheckBaseline();
|
||||
};
|
||||
|
||||
registerEvent(
|
||||
"getDownloadSourcesCheckBaseline",
|
||||
getDownloadSourcesCheckBaselineHandler
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getDownloadSourcesSinceValue } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDownloadSourcesSinceValueHandler = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
) => {
|
||||
return await getDownloadSourcesSinceValue();
|
||||
};
|
||||
|
||||
registerEvent(
|
||||
"getDownloadSourcesSinceValue",
|
||||
getDownloadSourcesSinceValueHandler
|
||||
);
|
||||
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,98 +1,22 @@
|
||||
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import "./catalogue/get-game-shop-details";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/get-game-stats";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./hardware/check-folder-write-permission";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/add-custom-game-to-library";
|
||||
import "./library/update-custom-game";
|
||||
import "./library/update-game-custom-assets";
|
||||
import "./library/add-game-to-favorites";
|
||||
import "./library/remove-game-from-favorites";
|
||||
import "./library/toggle-game-pin";
|
||||
import "./library/create-game-shortcut";
|
||||
import "./library/close-game";
|
||||
import "./library/delete-game-folder";
|
||||
import "./library/get-game-by-object-id";
|
||||
import "./library/get-library";
|
||||
import "./library/extract-game-download";
|
||||
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 "./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 "./download-sources/remove-download-source";
|
||||
import "./download-sources/get-download-sources";
|
||||
import "./auth";
|
||||
import "./autoupdater";
|
||||
import "./catalogue";
|
||||
import "./cloud-save";
|
||||
import "./download-sources";
|
||||
import "./hardware";
|
||||
import "./library";
|
||||
import "./leveldb";
|
||||
import "./misc";
|
||||
import "./notifications";
|
||||
import "./profile";
|
||||
import "./themes";
|
||||
import "./torrenting";
|
||||
import "./user";
|
||||
import "./user-preferences";
|
||||
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
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);
|
||||
27
src/main/events/library/clear-new-download-options.ts
Normal file
27
src/main/events/library/clear-new-download-options.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { logger } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const clearNewDownloadOptions = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
|
||||
try {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
newDownloadOptionsCount: undefined,
|
||||
});
|
||||
logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to clear newDownloadOptionsCount: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("clearNewDownloadOptions", clearNewDownloadOptions);
|
||||
@@ -2,6 +2,7 @@ import type { LibraryGame } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import {
|
||||
downloadsSublevel,
|
||||
gameAchievementsSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
gamesSublevel,
|
||||
} from "@main/level";
|
||||
@@ -18,15 +19,28 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
const download = await downloadsSublevel.get(key);
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
||||
|
||||
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
|
||||
|
||||
if (!game.unlockedAchievementCount) {
|
||||
const achievements = await gameAchievementsSublevel.get(key);
|
||||
|
||||
unlockedAchievementCount =
|
||||
achievements?.unlockedAchievements.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...game,
|
||||
download: download ?? null,
|
||||
unlockedAchievementCount,
|
||||
achievementCount: game.achievementCount ?? 0,
|
||||
// Spread gameAssets last to ensure all image URLs are properly set
|
||||
...gameAssets,
|
||||
// Ensure compatibility with LibraryGame type
|
||||
libraryHeroImageUrl:
|
||||
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
|
||||
} as LibraryGame;
|
||||
// Preserve custom image URLs from game if they exist
|
||||
customIconUrl: game.customIconUrl,
|
||||
customLogoImageUrl: game.customLogoImageUrl,
|
||||
customHeroImageUrl: game.customHeroImageUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
32
src/main/events/library/index.ts
Normal file
32
src/main/events/library/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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-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";
|
||||
8
src/main/events/library/refresh-library-assets.ts
Normal file
8
src/main/events/library/refresh-library-assets.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { mergeWithRemoteGames } from "@main/services";
|
||||
|
||||
const refreshLibraryAssets = async () => {
|
||||
await mergeWithRemoteGames();
|
||||
};
|
||||
|
||||
registerEvent("refreshLibraryAssets", refreshLibraryAssets);
|
||||
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";
|
||||
3
src/main/events/notifications/index.ts
Normal file
3
src/main/events/notifications/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./publish-new-repacks-notification";
|
||||
import "./show-achievement-test-notification";
|
||||
import "./update-achievement-notification-window";
|
||||
4
src/main/events/profile/index.ts
Normal file
4
src/main/events/profile/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import "./get-me";
|
||||
import "./process-profile-image";
|
||||
import "./sync-friend-requests";
|
||||
import "./update-profile";
|
||||
@@ -1,16 +1,20 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { PythonRPC } from "@main/services/python-rpc";
|
||||
|
||||
const processProfileImage = async (
|
||||
const processProfileImageEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
path: string
|
||||
) => {
|
||||
return processProfileImage(path, "webp");
|
||||
};
|
||||
|
||||
export const processProfileImage = async (path: string, extension?: string) => {
|
||||
return PythonRPC.rpc
|
||||
.post<{
|
||||
imagePath: string;
|
||||
mimeType: string;
|
||||
}>("/profile-image", { image_path: path })
|
||||
}>("/profile-image", { image_path: path, target_extension: extension })
|
||||
.then((response) => response.data);
|
||||
};
|
||||
|
||||
registerEvent("processProfileImage", processProfileImage);
|
||||
registerEvent("processProfileImage", processProfileImageEvent);
|
||||
|
||||
40
src/main/events/themes/copy-theme-achievement-sound.ts
Normal file
40
src/main/events/themes/copy-theme-achievement-sound.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getThemePath } from "@main/helpers";
|
||||
import { themesSublevel } from "@main/level";
|
||||
|
||||
const copyThemeAchievementSound = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string,
|
||||
sourcePath: string
|
||||
): Promise<void> => {
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||
throw new Error("Source file does not exist");
|
||||
}
|
||||
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
if (!theme) {
|
||||
throw new Error("Theme not found");
|
||||
}
|
||||
|
||||
const themeDir = getThemePath(themeId, theme.name);
|
||||
|
||||
if (!fs.existsSync(themeDir)) {
|
||||
fs.mkdirSync(themeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileExtension = path.extname(sourcePath);
|
||||
const destinationPath = path.join(themeDir, `achievement${fileExtension}`);
|
||||
|
||||
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||
|
||||
await themesSublevel.put(themeId, {
|
||||
...theme,
|
||||
hasCustomSound: true,
|
||||
originalSoundPath: sourcePath,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("copyThemeAchievementSound", copyThemeAchievementSound);
|
||||
40
src/main/events/themes/get-theme-sound-data-url.ts
Normal file
40
src/main/events/themes/get-theme-sound-data-url.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
import { themesSublevel } from "@main/level";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
const getThemeSoundDataUrl = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
const soundPath = getThemeSoundPath(themeId, theme?.name);
|
||||
|
||||
if (!soundPath || !fs.existsSync(soundPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await fs.promises.readFile(soundPath);
|
||||
const ext = path.extname(soundPath).toLowerCase().slice(1);
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || "audio/mpeg";
|
||||
const base64 = buffer.toString("base64");
|
||||
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
logger.error("Failed to get theme sound data URL", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl);
|
||||
13
src/main/events/themes/get-theme-sound-path.ts
Normal file
13
src/main/events/themes/get-theme-sound-path.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
import { themesSublevel } from "@main/level";
|
||||
|
||||
const getThemeSoundPathEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
): Promise<string | null> => {
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
return getThemeSoundPath(themeId, theme?.name);
|
||||
};
|
||||
|
||||
registerEvent("getThemeSoundPath", getThemeSoundPathEvent);
|
||||
60
src/main/events/themes/import-theme-sound-from-store.ts
Normal file
60
src/main/events/themes/import-theme-sound-from-store.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import axios from "axios";
|
||||
import { getThemePath } from "@main/helpers";
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
const importThemeSoundFromStore = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string,
|
||||
themeName: string,
|
||||
storeUrl: string
|
||||
): Promise<void> => {
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
if (!theme) {
|
||||
throw new Error("Theme not found");
|
||||
}
|
||||
|
||||
const formats = ["wav", "mp3", "ogg", "m4a"];
|
||||
|
||||
for (const format of formats) {
|
||||
try {
|
||||
const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`;
|
||||
|
||||
const response = await axios.get(soundUrl, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const themeDir = getThemePath(themeId, theme.name);
|
||||
|
||||
if (!fs.existsSync(themeDir)) {
|
||||
fs.mkdirSync(themeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const destinationPath = path.join(themeDir, `achievement.${format}`);
|
||||
await fs.promises.writeFile(destinationPath, response.data);
|
||||
|
||||
await themesSublevel.put(themeId, {
|
||||
...theme,
|
||||
hasCustomSound: true,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
logger.log(`Successfully imported sound for theme ${themeName}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to import ${format} sound for theme ${themeName}`,
|
||||
error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`No sound file found for theme ${themeName} in store`);
|
||||
};
|
||||
|
||||
registerEvent("importThemeSoundFromStore", importThemeSoundFromStore);
|
||||
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";
|
||||
48
src/main/events/themes/remove-theme-achievement-sound.ts
Normal file
48
src/main/events/themes/remove-theme-achievement-sound.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import { getThemePath } from "@main/helpers";
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { THEMES_PATH } from "@main/constants";
|
||||
import path from "node:path";
|
||||
|
||||
const removeThemeAchievementSound = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
): Promise<void> => {
|
||||
const theme = await themesSublevel.get(themeId);
|
||||
if (!theme) {
|
||||
throw new Error("Theme not found");
|
||||
}
|
||||
|
||||
const themeDir = getThemePath(themeId, theme.name);
|
||||
const legacyThemeDir = path.join(THEMES_PATH, themeId);
|
||||
|
||||
const removeFromDir = async (dir: string) => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formats = ["wav", "mp3", "ogg", "m4a"];
|
||||
|
||||
for (const format of formats) {
|
||||
const soundPath = path.join(dir, `achievement.${format}`);
|
||||
if (fs.existsSync(soundPath)) {
|
||||
await fs.promises.unlink(soundPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await removeFromDir(themeDir);
|
||||
if (themeDir !== legacyThemeDir) {
|
||||
await removeFromDir(legacyThemeDir);
|
||||
}
|
||||
|
||||
await themesSublevel.put(themeId, {
|
||||
...theme,
|
||||
hasCustomSound: false,
|
||||
originalSoundPath: undefined,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("removeThemeAchievementSound", removeThemeAchievementSound);
|
||||
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);
|
||||
|
||||
if (download?.status === "paused") {
|
||||
if (
|
||||
download &&
|
||||
(download.status === "paused" || download.status === "active") &&
|
||||
download.progress !== 1
|
||||
) {
|
||||
await DownloadManager.pauseDownload();
|
||||
|
||||
for await (const [key, value] of downloadsSublevel.iterator()) {
|
||||
|
||||
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";
|
||||
@@ -2,6 +2,8 @@ import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
import UserAgent from "user-agents";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { THEMES_PATH } from "@main/constants";
|
||||
|
||||
export const getFileBuffer = async (url: string) =>
|
||||
fetch(url, { method: "GET" }).then((response) =>
|
||||
@@ -31,9 +33,64 @@ export const isPortableVersion = () => {
|
||||
};
|
||||
|
||||
export const normalizePath = (str: string) =>
|
||||
path.posix.normalize(str).replace(/\\/g, "/");
|
||||
path.posix.normalize(str).replaceAll("\\", "/");
|
||||
|
||||
export const addTrailingSlash = (str: string) =>
|
||||
str.endsWith("/") ? str : `${str}/`;
|
||||
|
||||
const sanitizeFolderName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9-_\s]/g, "")
|
||||
.replaceAll(/\s+/g, "-")
|
||||
.replaceAll(/-+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
};
|
||||
|
||||
export const getThemePath = (themeId: string, themeName?: string): string => {
|
||||
if (themeName) {
|
||||
const sanitizedName = sanitizeFolderName(themeName);
|
||||
if (sanitizedName) {
|
||||
return path.join(THEMES_PATH, sanitizedName);
|
||||
}
|
||||
}
|
||||
return path.join(THEMES_PATH, themeId);
|
||||
};
|
||||
|
||||
export const getThemeSoundPath = (
|
||||
themeId: string,
|
||||
themeName?: string
|
||||
): string | null => {
|
||||
const themeDir = getThemePath(themeId, themeName);
|
||||
const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null;
|
||||
|
||||
const checkDir = (dir: string): string | null => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formats = ["wav", "mp3", "ogg", "m4a"];
|
||||
|
||||
for (const format of formats) {
|
||||
const soundPath = path.join(dir, `achievement.${format}`);
|
||||
if (fs.existsSync(soundPath)) {
|
||||
return soundPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const soundPath = checkDir(themeDir);
|
||||
if (soundPath) {
|
||||
return soundPath;
|
||||
}
|
||||
|
||||
if (legacyThemeDir) {
|
||||
return checkDir(legacyThemeDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export * from "./reg-parser";
|
||||
|
||||
67
src/main/level/sublevels/downloadSourcesCheckTimestamp.ts
Normal file
67
src/main/level/sublevels/downloadSourcesCheckTimestamp.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { levelKeys } from "./keys";
|
||||
import { db } from "../level";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
// Gets when we last started the app (for next API call's 'since')
|
||||
export const getDownloadSourcesCheckBaseline = async (): Promise<
|
||||
string | null
|
||||
> => {
|
||||
try {
|
||||
const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
return timestamp;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "NotFoundError") {
|
||||
logger.debug("Download sources check baseline not found, returning null");
|
||||
} else {
|
||||
logger.error(
|
||||
"Unexpected error while getting download sources check baseline",
|
||||
error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Updates to current time (when app starts)
|
||||
export const updateDownloadSourcesCheckBaseline = async (
|
||||
timestamp: string
|
||||
): Promise<void> => {
|
||||
const utcTimestamp = new Date(timestamp).toISOString();
|
||||
await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
};
|
||||
|
||||
// Gets the 'since' value the API used in the last check (for modal comparison)
|
||||
export const getDownloadSourcesSinceValue = async (): Promise<
|
||||
string | null
|
||||
> => {
|
||||
try {
|
||||
const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
return timestamp;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "NotFoundError") {
|
||||
logger.debug("Download sources since value not found, returning null");
|
||||
} else {
|
||||
logger.error(
|
||||
"Unexpected error while getting download sources since value",
|
||||
error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Saves the 'since' value we used in the API call (for modal to compare against)
|
||||
export const updateDownloadSourcesSinceValue = async (
|
||||
timestamp: string
|
||||
): Promise<void> => {
|
||||
const utcTimestamp = new Date(timestamp).toISOString();
|
||||
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, {
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
};
|
||||
@@ -7,3 +7,4 @@ export * from "./game-achievements";
|
||||
export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
|
||||
@@ -18,4 +18,6 @@ export const levelKeys = {
|
||||
screenState: "screenState",
|
||||
rpcPassword: "rpcPassword",
|
||||
downloadSources: "downloadSources",
|
||||
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { Downloader } from "@shared";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Ludusavi,
|
||||
Lock,
|
||||
DeckyPlugin,
|
||||
DownloadSourcesChecker,
|
||||
WSClient,
|
||||
} from "@main/services";
|
||||
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
||||
@@ -57,6 +58,9 @@ export const loadState = async () => {
|
||||
|
||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||
void syncDownloadSourcesFromApi();
|
||||
|
||||
// Check for new download options on startup
|
||||
DownloadSourcesChecker.checkForChanges();
|
||||
WSClient.connect();
|
||||
});
|
||||
|
||||
@@ -64,7 +68,7 @@ export const loadState = async () => {
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return sortBy(games, "timestamp", "DESC");
|
||||
return orderBy(games, "timestamp", "desc");
|
||||
});
|
||||
|
||||
downloads.forEach((download) => {
|
||||
|
||||
188
src/main/services/download-sources-checker.ts
Normal file
188
src/main/services/download-sources-checker.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { HydraApi } from "./hydra-api";
|
||||
import {
|
||||
gamesSublevel,
|
||||
getDownloadSourcesCheckBaseline,
|
||||
updateDownloadSourcesCheckBaseline,
|
||||
updateDownloadSourcesSinceValue,
|
||||
downloadSourcesSublevel,
|
||||
} from "@main/level";
|
||||
import { logger } from "./logger";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import type { Game } from "@types";
|
||||
|
||||
interface DownloadSourcesChangeResponse {
|
||||
shop: string;
|
||||
objectId: string;
|
||||
newDownloadOptionsCount: number;
|
||||
downloadSourceIds: string[];
|
||||
}
|
||||
|
||||
export class DownloadSourcesChecker {
|
||||
private static async clearStaleBadges(
|
||||
nonCustomGames: Game[]
|
||||
): Promise<{ gameId: string; count: number }[]> {
|
||||
const previouslyFlaggedGames = nonCustomGames.filter(
|
||||
(game: Game) =>
|
||||
game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0
|
||||
);
|
||||
|
||||
const clearedPayload: { gameId: string; count: number }[] = [];
|
||||
if (previouslyFlaggedGames.length > 0) {
|
||||
logger.info(
|
||||
`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`
|
||||
);
|
||||
for (const game of previouslyFlaggedGames) {
|
||||
await gamesSublevel.put(`${game.shop}:${game.objectId}`, {
|
||||
...game,
|
||||
newDownloadOptionsCount: undefined,
|
||||
});
|
||||
clearedPayload.push({
|
||||
gameId: `${game.shop}:${game.objectId}`,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clearedPayload;
|
||||
}
|
||||
|
||||
private static async processApiResponse(
|
||||
response: unknown,
|
||||
nonCustomGames: Game[]
|
||||
): Promise<{ gameId: string; count: number }[]> {
|
||||
if (!response || !Array.isArray(response)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gamesWithNewOptions: { gameId: string; count: number }[] = [];
|
||||
|
||||
for (const gameUpdate of response as DownloadSourcesChangeResponse[]) {
|
||||
if (gameUpdate.newDownloadOptionsCount > 0) {
|
||||
const game = nonCustomGames.find(
|
||||
(g) =>
|
||||
g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId
|
||||
);
|
||||
|
||||
if (game) {
|
||||
await gamesSublevel.put(`${game.shop}:${game.objectId}`, {
|
||||
...game,
|
||||
newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount,
|
||||
});
|
||||
|
||||
gamesWithNewOptions.push({
|
||||
gameId: `${game.shop}:${game.objectId}`,
|
||||
count: gameUpdate.newDownloadOptionsCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gamesWithNewOptions;
|
||||
}
|
||||
|
||||
private static sendNewDownloadOptionsEvent(
|
||||
clearedPayload: { gameId: string; count: number }[],
|
||||
gamesWithNewOptions: { gameId: string; count: number }[]
|
||||
): void {
|
||||
const eventPayload = [...clearedPayload, ...gamesWithNewOptions];
|
||||
if (eventPayload.length > 0 && WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-new-download-options",
|
||||
eventPayload
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Found new download options for ${gamesWithNewOptions.length} games`
|
||||
);
|
||||
}
|
||||
|
||||
static async checkForChanges(): Promise<void> {
|
||||
logger.info("DownloadSourcesChecker.checkForChanges() called");
|
||||
|
||||
try {
|
||||
// Get all installed games (excluding custom games)
|
||||
const installedGames = await gamesSublevel.values().all();
|
||||
const nonCustomGames = installedGames.filter(
|
||||
(game: Game) => game.shop !== "custom"
|
||||
);
|
||||
logger.info(
|
||||
`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`
|
||||
);
|
||||
|
||||
if (nonCustomGames.length === 0) {
|
||||
logger.info(
|
||||
"No non-custom games found, skipping download sources check"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadSources = await downloadSourcesSublevel.values().all();
|
||||
const downloadSourceIds = downloadSources.map((source) => source.id);
|
||||
logger.info(
|
||||
`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(", ")}`
|
||||
);
|
||||
|
||||
if (downloadSourceIds.length === 0) {
|
||||
logger.info(
|
||||
"No download sources found, skipping download sources check"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBaseline = await getDownloadSourcesCheckBaseline();
|
||||
const since =
|
||||
previousBaseline ||
|
||||
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
logger.info(`Using since: ${since} (from last app start)`);
|
||||
|
||||
const clearedPayload = await this.clearStaleBadges(nonCustomGames);
|
||||
|
||||
const games = nonCustomGames.map((game: Game) => ({
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
`Checking download sources changes for ${games.length} non-custom games since ${since}`
|
||||
);
|
||||
logger.info(
|
||||
`Making API call to HydraApi.checkDownloadSourcesChanges with:`,
|
||||
{
|
||||
downloadSourceIds,
|
||||
gamesCount: games.length,
|
||||
since,
|
||||
}
|
||||
);
|
||||
|
||||
const response = await HydraApi.checkDownloadSourcesChanges(
|
||||
downloadSourceIds,
|
||||
games,
|
||||
since
|
||||
);
|
||||
|
||||
logger.info("API call completed, response:", response);
|
||||
|
||||
await updateDownloadSourcesSinceValue(since);
|
||||
logger.info(`Saved 'since' value: ${since} (for modal comparison)`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await updateDownloadSourcesCheckBaseline(now);
|
||||
logger.info(
|
||||
`Updated baseline to: ${now} (will be 'since' on next app start)`
|
||||
);
|
||||
|
||||
const gamesWithNewOptions = await this.processApiResponse(
|
||||
response,
|
||||
nonCustomGames
|
||||
);
|
||||
|
||||
this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions);
|
||||
|
||||
logger.info("Download sources check completed successfully");
|
||||
} catch (error) {
|
||||
logger.error("Failed to check download sources changes:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { RealDebridClient } from "./real-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { GameFilesManager } from "../game-files-manager";
|
||||
import { HydraDebridClient } from "./hydra-debrid";
|
||||
@@ -194,10 +194,10 @@ export class DownloadManager {
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return sortBy(
|
||||
return orderBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
"desc"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
||||
|
||||
private static secondsToMilliseconds(seconds: number) {
|
||||
return seconds * 1000;
|
||||
@@ -400,4 +400,45 @@ export class HydraApi {
|
||||
.then((response) => response.data)
|
||||
.catch(this.handleUnauthorizedError);
|
||||
}
|
||||
|
||||
static async checkDownloadSourcesChanges(
|
||||
downloadSourceIds: string[],
|
||||
games: Array<{ shop: string; objectId: string }>,
|
||||
since: string
|
||||
) {
|
||||
logger.info("HydraApi.checkDownloadSourcesChanges called with:", {
|
||||
downloadSourceIds,
|
||||
gamesCount: games.length,
|
||||
since,
|
||||
isLoggedIn: this.isLoggedIn(),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.post<
|
||||
Array<{
|
||||
shop: string;
|
||||
objectId: string;
|
||||
newDownloadOptionsCount: number;
|
||||
downloadSourceIds: string[];
|
||||
}>
|
||||
>(
|
||||
"/download-sources/changes",
|
||||
{
|
||||
downloadSourceIds,
|
||||
games,
|
||||
since,
|
||||
},
|
||||
{ needsAuth: true }
|
||||
);
|
||||
|
||||
logger.info(
|
||||
"HydraApi.checkDownloadSourcesChanges completed successfully:",
|
||||
result
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("HydraApi.checkDownloadSourcesChanges failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from "./wine";
|
||||
export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
|
||||
@@ -9,6 +9,8 @@ type ProfileGame = {
|
||||
hasManuallyUpdatedPlaytime: boolean;
|
||||
isFavorite?: boolean;
|
||||
isPinned?: boolean;
|
||||
achievementCount: number;
|
||||
unlockedAchievementCount: number;
|
||||
} & ShopAssets;
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
@@ -39,6 +41,8 @@ export const mergeWithRemoteGames = async () => {
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
favorite: game.isFavorite ?? localGame.favorite,
|
||||
isPinned: game.isPinned ?? localGame.isPinned,
|
||||
achievementCount: game.achievementCount,
|
||||
unlockedAchievementCount: game.unlockedAchievementCount,
|
||||
});
|
||||
} else {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
@@ -55,18 +59,27 @@ export const mergeWithRemoteGames = async () => {
|
||||
isDeleted: false,
|
||||
favorite: game.isFavorite ?? false,
|
||||
isPinned: game.isPinned ?? false,
|
||||
achievementCount: game.achievementCount,
|
||||
unlockedAchievementCount: game.unlockedAchievementCount,
|
||||
});
|
||||
}
|
||||
|
||||
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
|
||||
|
||||
// Construct coverImageUrl if not provided by backend (Steam games use predictable pattern)
|
||||
const coverImageUrl =
|
||||
game.coverImageUrl ||
|
||||
(game.shop === "steam"
|
||||
? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg`
|
||||
: null);
|
||||
|
||||
await gamesShopAssetsSublevel.put(gameKey, {
|
||||
updatedAt: Date.now(),
|
||||
...localGameShopAsset,
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: localGame?.title || game.title, // Preserve local title if it exists
|
||||
coverImageUrl: game.coverImageUrl,
|
||||
coverImageUrl,
|
||||
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
||||
libraryImageUrl: game.libraryImageUrl,
|
||||
logoImageUrl: game.logoImageUrl,
|
||||
|
||||
@@ -11,9 +11,17 @@ import { NotificationOptions, toXmlString } from "./xml";
|
||||
import { logger } from "../logger";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { Game, UserPreferences, UserProfile } from "@types";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import { db, levelKeys, themesSublevel } from "@main/level";
|
||||
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
|
||||
import { SystemPath } from "../system-path";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||
|
||||
const getStaticImage = async (path: string) => {
|
||||
return processProfileImage(path, "jpg")
|
||||
.then((response) => response.imagePath)
|
||||
.catch(() => path);
|
||||
};
|
||||
|
||||
async function downloadImage(url: string | null) {
|
||||
if (!url) return undefined;
|
||||
@@ -30,8 +38,9 @@ async function downloadImage(url: string | null) {
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
writer.on("finish", () => {
|
||||
resolve(outputPath);
|
||||
writer.on("finish", async () => {
|
||||
const staticImagePath = await getStaticImage(outputPath);
|
||||
resolve(staticImagePath);
|
||||
});
|
||||
writer.on("error", () => {
|
||||
logger.error("Failed to download image", { url });
|
||||
@@ -40,6 +49,27 @@ async function downloadImage(url: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getAchievementSoundPath(): Promise<string> {
|
||||
try {
|
||||
const allThemes = await themesSublevel.values().all();
|
||||
const activeTheme = allThemes.find((theme) => theme.isActive);
|
||||
|
||||
if (activeTheme?.hasCustomSound) {
|
||||
const themeSoundPath = getThemeSoundPath(
|
||||
activeTheme.id,
|
||||
activeTheme.name
|
||||
);
|
||||
if (themeSoundPath) {
|
||||
return themeSoundPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to get theme sound path", error);
|
||||
}
|
||||
|
||||
return achievementSoundPath;
|
||||
}
|
||||
|
||||
export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
const userPreferences = await db.get<string, UserPreferences>(
|
||||
levelKeys.userPreferences,
|
||||
@@ -145,7 +175,8 @@ export const publishCombinedNewAchievementNotification = async (
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
|
||||
} else if (process.platform !== "linux") {
|
||||
sound.play(achievementSoundPath);
|
||||
const soundPath = await getAchievementSoundPath();
|
||||
sound.play(soundPath);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,6 +236,7 @@ export const publishNewAchievementNotification = async (info: {
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
|
||||
} else if (process.platform !== "linux") {
|
||||
sound.play(achievementSoundPath);
|
||||
const soundPath = await getAchievementSoundPath();
|
||||
sound.play(soundPath);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => {
|
||||
if (!steamGameUrl) return null;
|
||||
|
||||
return {
|
||||
title: $title.textContent,
|
||||
title: $title.getAttribute("data-title") || "",
|
||||
objectId: steamGameUrl.split("/").pop(),
|
||||
} as Steam250Game;
|
||||
})
|
||||
|
||||
@@ -13,9 +13,9 @@ export class SystemPath {
|
||||
};
|
||||
|
||||
static checkIfPathsAreAvailable() {
|
||||
const paths = Object.keys(SystemPath.paths) as Array<
|
||||
keyof typeof SystemPath.paths
|
||||
>;
|
||||
const paths = Object.keys(
|
||||
SystemPath.paths
|
||||
) as (keyof typeof SystemPath.paths)[];
|
||||
|
||||
paths.forEach((pathName) => {
|
||||
try {
|
||||
|
||||
@@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => {
|
||||
friendRequestCount: payload.friendRequestCount,
|
||||
});
|
||||
|
||||
const user = await HydraApi.get(`/users/${payload.senderId}`);
|
||||
if (payload.senderId) {
|
||||
const user = await HydraApi.get(`/users/${payload.senderId}`);
|
||||
|
||||
if (user) {
|
||||
publishNewFriendRequestNotification(user);
|
||||
if (user) {
|
||||
publishNewFriendRequestNotification(user);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
|
||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
|
||||
getDownloadSourcesCheckBaseline: () =>
|
||||
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
|
||||
getDownloadSourcesSinceValue: () =>
|
||||
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
|
||||
|
||||
/* Library */
|
||||
toggleAutomaticCloudSync: (
|
||||
@@ -179,6 +183,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
||||
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
||||
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
|
||||
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
|
||||
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
|
||||
updateLaunchOptions: (
|
||||
@@ -196,6 +202,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
verifyExecutablePathInUse: (executablePath: string) =>
|
||||
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
||||
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
||||
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
||||
openGameInstaller: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
||||
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
||||
@@ -570,6 +577,25 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
|
||||
toggleCustomTheme: (themeId: string, isActive: boolean) =>
|
||||
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
|
||||
copyThemeAchievementSound: (themeId: string, sourcePath: string) =>
|
||||
ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath),
|
||||
removeThemeAchievementSound: (themeId: string) =>
|
||||
ipcRenderer.invoke("removeThemeAchievementSound", themeId),
|
||||
getThemeSoundPath: (themeId: string) =>
|
||||
ipcRenderer.invoke("getThemeSoundPath", themeId),
|
||||
getThemeSoundDataUrl: (themeId: string) =>
|
||||
ipcRenderer.invoke("getThemeSoundDataUrl", themeId),
|
||||
importThemeSoundFromStore: (
|
||||
themeId: string,
|
||||
themeName: string,
|
||||
storeUrl: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"importThemeSoundFromStore",
|
||||
themeId,
|
||||
themeName,
|
||||
storeUrl
|
||||
),
|
||||
|
||||
/* Editor */
|
||||
openEditorWindow: (themeId: string) =>
|
||||
@@ -580,6 +606,41 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-custom-theme-updated", listener);
|
||||
},
|
||||
onNewDownloadOptions: (
|
||||
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
gamesWithNewOptions: { gameId: string; count: number }[]
|
||||
) => cb(gamesWithNewOptions);
|
||||
ipcRenderer.on("on-new-download-options", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-new-download-options", listener);
|
||||
},
|
||||
closeEditorWindow: (themeId?: string) =>
|
||||
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>
|
||||
<meta
|
||||
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>
|
||||
<body>
|
||||
|
||||
@@ -90,6 +90,7 @@ img {
|
||||
|
||||
progress[value] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener";
|
||||
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@@ -25,7 +25,14 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
|
||||
import { injectCustomCss, removeCustomCss } from "./helpers";
|
||||
import {
|
||||
injectCustomCss,
|
||||
removeCustomCss,
|
||||
getAchievementSoundUrl,
|
||||
getAchievementSoundVolume,
|
||||
} from "./helpers";
|
||||
import { levelDBService } from "./services/leveldb.service";
|
||||
import type { UserPreferences } from "@types";
|
||||
import "./app.scss";
|
||||
|
||||
export interface AppProps {
|
||||
@@ -36,6 +43,9 @@ export function App() {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { updateLibrary, library } = useLibrary();
|
||||
|
||||
// Listen for new download options updates
|
||||
useDownloadOptionsListener();
|
||||
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
@@ -69,11 +79,12 @@ export function App() {
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
|
||||
([preferences]) => {
|
||||
dispatch(setUserPreferences(preferences));
|
||||
}
|
||||
);
|
||||
Promise.all([
|
||||
levelDBService.get("userPreferences", null, "json"),
|
||||
updateLibrary(),
|
||||
]).then(([preferences]) => {
|
||||
dispatch(setUserPreferences(preferences as UserPreferences | null));
|
||||
});
|
||||
}, [navigate, location.pathname, dispatch, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,7 +207,11 @@ export function App() {
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
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) {
|
||||
injectCustomCss(activeTheme.code);
|
||||
} else {
|
||||
@@ -216,9 +231,11 @@ export function App() {
|
||||
return () => unsubscribe();
|
||||
}, [loadAndApplyTheme]);
|
||||
|
||||
const playAudio = useCallback(() => {
|
||||
const audio = new Audio(achievementSound);
|
||||
audio.volume = 0.2;
|
||||
const playAudio = useCallback(async () => {
|
||||
const soundUrl = await getAchievementSoundUrl();
|
||||
const volume = await getAchievementSoundVolume();
|
||||
const audio = new Audio(soundUrl);
|
||||
audio.volume = volume;
|
||||
audio.play();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface DropdownMenuProps {
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
collisionPadding?: number;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
@@ -29,6 +30,7 @@ export function DropdownMenu({
|
||||
loop = true,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
collisionPadding = 16,
|
||||
}: Readonly<DropdownMenuProps>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root>
|
||||
@@ -43,6 +45,7 @@ export function DropdownMenu({
|
||||
loop={loop}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className="dropdown-menu__content"
|
||||
>
|
||||
{title && (
|
||||
|
||||
@@ -47,7 +47,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
>
|
||||
<div className="game-card__backdrop">
|
||||
<img
|
||||
src={game.libraryImageUrl}
|
||||
src={game.libraryImageUrl ?? undefined}
|
||||
alt={game.title}
|
||||
className="game-card__cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -70,8 +70,10 @@ export function GameContextMenu({
|
||||
onClick: () => {
|
||||
if (isGameRunning) {
|
||||
void handleCloseGame();
|
||||
} else {
|
||||
} else if (canPlay) {
|
||||
void handlePlayGame();
|
||||
} else {
|
||||
handleOpenDownloadOptions();
|
||||
}
|
||||
},
|
||||
disabled: isDeleting,
|
||||
|
||||
@@ -3,22 +3,32 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
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 { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
import { setFilters } from "@renderer/features";
|
||||
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
|
||||
import cn from "classnames";
|
||||
import { SearchDropdown } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "home",
|
||||
"/catalogue": "catalogue",
|
||||
"/library": "library",
|
||||
"/downloads": "downloads",
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -27,32 +37,95 @@ export function Header() {
|
||||
(state) => state.window
|
||||
);
|
||||
|
||||
const searchValue = useAppSelector(
|
||||
const catalogueSearchValue = useAppSelector(
|
||||
(state) => state.catalogueSearch.filters.title
|
||||
);
|
||||
|
||||
const librarySearchValue = useAppSelector(
|
||||
(state) => state.library.searchQuery
|
||||
);
|
||||
|
||||
const isOnLibraryPage = location.pathname.startsWith("/library");
|
||||
const isOnCataloguePage = location.pathname.startsWith("/catalogue");
|
||||
|
||||
const searchValue = isOnLibraryPage
|
||||
? librarySearchValue
|
||||
: catalogueSearchValue;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
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 { 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(() => {
|
||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||
if (location.pathname.startsWith("/library"))
|
||||
return headerTitle || t("library");
|
||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||
|
||||
return t(pathTitle[location.pathname]);
|
||||
}, [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 = () => {
|
||||
setIsFocused(true);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isFocused && isDropdownVisible) {
|
||||
updateDropdownPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFocused(true);
|
||||
setActiveIndex(-1);
|
||||
setTimeout(() => {
|
||||
updateDropdownPosition();
|
||||
setIsDropdownVisible(true);
|
||||
}, 220);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
setTimeout(() => {
|
||||
setIsFocused(false);
|
||||
setIsDropdownVisible(false);
|
||||
setActiveIndex(-1);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
@@ -60,18 +133,121 @@ export function Header() {
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
dispatch(setFilters({ title: value.slice(0, 255) }));
|
||||
if (isOnLibraryPage) {
|
||||
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
|
||||
} else {
|
||||
dispatch(setFilters({ title: value.slice(0, 255) }));
|
||||
}
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
if (!location.pathname.startsWith("/catalogue")) {
|
||||
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 = () => {
|
||||
if (isOnLibraryPage) {
|
||||
dispatch(setLibrarySearchQuery(""));
|
||||
} else {
|
||||
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(() => {
|
||||
if (!location.pathname.startsWith("/catalogue") && searchValue) {
|
||||
const prevPath = sessionStorage.getItem("prevPath");
|
||||
const currentPath = location.pathname;
|
||||
|
||||
if (
|
||||
prevPath?.startsWith("/catalogue") &&
|
||||
!currentPath.startsWith("/catalogue") &&
|
||||
catalogueSearchValue
|
||||
) {
|
||||
dispatch(setFilters({ title: "" }));
|
||||
}
|
||||
}, [location.pathname, searchValue, dispatch]);
|
||||
|
||||
sessionStorage.setItem("prevPath", currentPath);
|
||||
}, [location.pathname, catalogueSearchValue, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownVisible) return;
|
||||
|
||||
const handleResize = () => {
|
||||
updateDropdownPosition();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isDropdownVisible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -104,6 +280,7 @@ export function Header() {
|
||||
|
||||
<section className="header__section">
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={cn("header__search", {
|
||||
"header__search--focused": isFocused,
|
||||
})}
|
||||
@@ -120,18 +297,19 @@ export function Header() {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
placeholder={isOnLibraryPage ? t("search_library") : t("search")}
|
||||
value={searchValue}
|
||||
className="header__search-input"
|
||||
onChange={(event) => handleSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(setFilters({ title: "" }))}
|
||||
onClick={handleClearSearch}
|
||||
className="header__action-button"
|
||||
>
|
||||
<XIcon />
|
||||
@@ -141,6 +319,27 @@ export function Header() {
|
||||
</section>
|
||||
</header>
|
||||
<AutoUpdateSubHeader />
|
||||
|
||||
<SearchDropdown
|
||||
visible={
|
||||
isDropdownVisible &&
|
||||
(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">
|
||||
<img
|
||||
src={game.libraryHeroImageUrl}
|
||||
src={game.libraryHeroImageUrl ?? undefined}
|
||||
alt={game.description ?? ""}
|
||||
className="hero__media"
|
||||
/>
|
||||
|
||||
<div className="hero__content">
|
||||
<img
|
||||
src={game.logoImageUrl}
|
||||
src={game.logoImageUrl ?? undefined}
|
||||
width="250px"
|
||||
alt={game.description ?? ""}
|
||||
loading="eager"
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from "./context-menu/context-menu";
|
||||
export * from "./game-context-menu/game-context-menu";
|
||||
export * from "./game-context-menu/use-game-actions";
|
||||
export * from "./star-rating/star-rating";
|
||||
export * from "./search-dropdown/search-dropdown";
|
||||
|
||||
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: 300px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
241
src/renderer/src/components/search-dropdown/search-dropdown.tsx
Normal file
241
src/renderer/src/components/search-dropdown/search-dropdown.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useRef, useCallback, 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]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(
|
||||
type: "history" | "suggestion",
|
||||
item: SearchHistoryEntry | SearchSuggestion
|
||||
) => {
|
||||
if (type === "history") {
|
||||
onSelectHistory((item as SearchHistoryEntry).query);
|
||||
} else {
|
||||
onSelectSuggestion(item as SearchSuggestion);
|
||||
}
|
||||
},
|
||||
[onSelectHistory, onSelectSuggestion]
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const totalItems = historyItems.length + suggestions.length;
|
||||
const hasHistory = historyItems.length > 0;
|
||||
const hasSuggestions = suggestions.length > 0;
|
||||
|
||||
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={() => handleItemClick("history", item)}
|
||||
>
|
||||
<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={() => handleItemClick("suggestion", 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>
|
||||
)}
|
||||
|
||||
{!isLoadingSuggestions &&
|
||||
!hasHistory &&
|
||||
!hasSuggestions &&
|
||||
totalItems === 0 && (
|
||||
<div className="search-dropdown__empty">{t("no_results")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(dropdownContent, document.body);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DownloadIcon,
|
||||
GearIcon,
|
||||
HomeIcon,
|
||||
BookIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
export const routes = [
|
||||
@@ -16,6 +17,11 @@ export const routes = [
|
||||
nameKey: "catalogue",
|
||||
render: () => <AppsIcon />,
|
||||
},
|
||||
{
|
||||
path: "/library",
|
||||
nameKey: "library",
|
||||
render: () => <BookIcon />,
|
||||
},
|
||||
{
|
||||
path: "/downloads",
|
||||
nameKey: "downloads",
|
||||
|
||||
@@ -80,6 +80,12 @@ export function SidebarGameItem({
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
|
||||
{(game.newDownloadOptionsCount ?? 0) > 0 && (
|
||||
<span className="sidebar__game-badge">
|
||||
+{game.newDownloadOptionsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -115,6 +115,19 @@
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
&__game-badge {
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
color: rgb(187, 247, 208);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
import {
|
||||
useAppDispatch,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
DownloadSource,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
@@ -297,7 +300,10 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const fetchDownloadSources = async () => {
|
||||
try {
|
||||
const sources = await window.electron.getDownloadSources();
|
||||
const sourcesRaw = (await levelDBService.values(
|
||||
"downloadSources"
|
||||
)) as DownloadSource[];
|
||||
const sources = orderBy(sourcesRaw, "createdAt", "desc");
|
||||
|
||||
const params = {
|
||||
take: 100,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { setUserPreferences } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
import type { UserBlocks, UserPreferences } from "@types";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
@@ -134,9 +135,11 @@ export function SettingsContextProvider({
|
||||
|
||||
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
|
||||
await window.electron.updateUserPreferences(values);
|
||||
window.electron.getUserPreferences().then((userPreferences) => {
|
||||
dispatch(setUserPreferences(userPreferences));
|
||||
});
|
||||
levelDBService
|
||||
.get("userPreferences", null, "json")
|
||||
.then((userPreferences) => {
|
||||
dispatch(setUserPreferences(userPreferences as UserPreferences | null));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
43
src/renderer/src/declaration.d.ts
vendored
43
src/renderer/src/declaration.d.ts
vendored
@@ -142,6 +142,10 @@ declare global {
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
clearNewDownloadOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
toggleGamePin: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -159,6 +163,7 @@ declare global {
|
||||
) => Promise<void>;
|
||||
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
|
||||
getLibrary: () => Promise<LibraryGame[]>;
|
||||
refreshLibraryAssets: () => Promise<void>;
|
||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
@@ -214,6 +219,8 @@ declare global {
|
||||
) => Promise<void>;
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
syncDownloadSources: () => Promise<void>;
|
||||
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
|
||||
getDownloadSourcesSinceValue: () => Promise<string | null>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
@@ -409,11 +416,47 @@ declare global {
|
||||
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
|
||||
getActiveCustomTheme: () => Promise<Theme | null>;
|
||||
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
|
||||
copyThemeAchievementSound: (
|
||||
themeId: string,
|
||||
sourcePath: string
|
||||
) => Promise<void>;
|
||||
removeThemeAchievementSound: (themeId: string) => Promise<void>;
|
||||
getThemeSoundPath: (themeId: string) => Promise<string | null>;
|
||||
getThemeSoundDataUrl: (themeId: string) => Promise<string | null>;
|
||||
importThemeSoundFromStore: (
|
||||
themeId: string,
|
||||
themeName: string,
|
||||
storeUrl: string
|
||||
) => Promise<void>;
|
||||
|
||||
/* Editor */
|
||||
openEditorWindow: (themeId: string) => Promise<void>;
|
||||
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
closeEditorWindow: (themeId?: string) => Promise<void>;
|
||||
|
||||
/* Download Options */
|
||||
onNewDownloadOptions: (
|
||||
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* LevelDB Generic CRUD */
|
||||
leveldb: {
|
||||
get: (
|
||||
key: string,
|
||||
sublevelName?: string | null,
|
||||
valueEncoding?: "json" | "utf8"
|
||||
) => Promise<unknown>;
|
||||
put: (
|
||||
key: string,
|
||||
value: unknown,
|
||||
sublevelName?: string | null,
|
||||
valueEncoding?: "json" | "utf8"
|
||||
) => Promise<void>;
|
||||
del: (key: string, sublevelName?: string | null) => Promise<void>;
|
||||
clear: (sublevelName: string) => Promise<void>;
|
||||
values: (sublevelName: string) => Promise<unknown[]>;
|
||||
iterator: (sublevelName: string) => Promise<[string, unknown][]>;
|
||||
};
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -5,10 +5,12 @@ import type { LibraryGame } from "@types";
|
||||
|
||||
export interface LibraryState {
|
||||
value: LibraryGame[];
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
const initialState: LibraryState = {
|
||||
value: [],
|
||||
searchQuery: "",
|
||||
};
|
||||
|
||||
export const librarySlice = createSlice({
|
||||
@@ -18,7 +20,34 @@ export const librarySlice = createSlice({
|
||||
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
|
||||
updateGameNewDownloadOptions: (
|
||||
state,
|
||||
action: PayloadAction<{ gameId: string; count: number }>
|
||||
) => {
|
||||
const game = state.value.find((g) => g.id === action.payload.gameId);
|
||||
if (game) {
|
||||
game.newDownloadOptionsCount = action.payload.count;
|
||||
}
|
||||
},
|
||||
clearNewDownloadOptions: (
|
||||
state,
|
||||
action: PayloadAction<{ gameId: string }>
|
||||
) => {
|
||||
const game = state.value.find((g) => g.id === action.payload.gameId);
|
||||
if (game) {
|
||||
game.newDownloadOptionsCount = undefined;
|
||||
}
|
||||
},
|
||||
setLibrarySearchQuery: (state, action: PayloadAction<string>) => {
|
||||
state.searchQuery = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLibrary } = librarySlice.actions;
|
||||
export const {
|
||||
setLibrary,
|
||||
updateGameNewDownloadOptions,
|
||||
clearNewDownloadOptions,
|
||||
setLibrarySearchQuery,
|
||||
} = librarySlice.actions;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GameShop } from "@types";
|
||||
import Color from "color";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { THEME_WEB_STORE_URL } from "./constants";
|
||||
import { levelDBService } from "./services/leveldb.service";
|
||||
|
||||
export const formatDownloadProgress = (
|
||||
progress?: number,
|
||||
@@ -121,3 +122,48 @@ export const formatNumber = (num: number): string => {
|
||||
export const generateUUID = (): string => {
|
||||
return uuidv4();
|
||||
};
|
||||
|
||||
export const getAchievementSoundUrl = async (): Promise<string> => {
|
||||
const defaultSound = (await import("@renderer/assets/audio/achievement.wav"))
|
||||
.default;
|
||||
|
||||
try {
|
||||
const allThemes = (await levelDBService.values("themes")) as {
|
||||
id: string;
|
||||
isActive?: boolean;
|
||||
hasCustomSound?: boolean;
|
||||
}[];
|
||||
const activeTheme = allThemes.find((theme) => theme.isActive);
|
||||
|
||||
if (activeTheme?.hasCustomSound) {
|
||||
const soundDataUrl = await window.electron.getThemeSoundDataUrl(
|
||||
activeTheme.id
|
||||
);
|
||||
if (soundDataUrl) {
|
||||
return soundDataUrl;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get theme sound", error);
|
||||
}
|
||||
|
||||
return defaultSound;
|
||||
};
|
||||
|
||||
export const getAchievementSoundVolume = async (): Promise<number> => {
|
||||
try {
|
||||
const prefs = (await levelDBService.get(
|
||||
"userPreferences",
|
||||
null,
|
||||
"json"
|
||||
)) as { achievementSoundVolume?: number } | null;
|
||||
return prefs?.achievementSoundVolume ?? 0.15;
|
||||
} catch (error) {
|
||||
console.error("Failed to get sound volume", error);
|
||||
return 0.15;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGameKey = (shop: GameShop, objectId: string): string => {
|
||||
return `${shop}:${objectId}`;
|
||||
};
|
||||
|
||||
@@ -6,3 +6,8 @@ export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-format";
|
||||
export * from "./use-feature";
|
||||
export * from "./use-download-options-listener";
|
||||
export * from "./use-game-card";
|
||||
export * from "./use-search-history";
|
||||
export * from "./use-search-suggestions";
|
||||
export * from "./use-hls-video";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
import type { DownloadSource } from "@types";
|
||||
import { useAppDispatch } from "./redux";
|
||||
import { setGenres, setTags } from "@renderer/features";
|
||||
import type { DownloadSource } from "@types";
|
||||
|
||||
export const externalResourcesInstance = axios.create({
|
||||
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
|
||||
@@ -40,8 +41,9 @@ export function useCatalogue() {
|
||||
}, []);
|
||||
|
||||
const getDownloadSources = useCallback(() => {
|
||||
window.electron.getDownloadSources().then((results) => {
|
||||
setDownloadSources(results.filter((source) => !!source.fingerprint));
|
||||
levelDBService.values("downloadSources").then((results) => {
|
||||
const sources = results as DownloadSource[];
|
||||
setDownloadSources(sources.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
19
src/renderer/src/hooks/use-download-options-listener.ts
Normal file
19
src/renderer/src/hooks/use-download-options-listener.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppDispatch } from "./redux";
|
||||
import { updateGameNewDownloadOptions } from "@renderer/features";
|
||||
|
||||
export function useDownloadOptionsListener() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onNewDownloadOptions(
|
||||
(gamesWithNewOptions) => {
|
||||
for (const { gameId, count } of gamesWithNewOptions) {
|
||||
dispatch(updateGameNewDownloadOptions({ gameId, count }));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [dispatch]);
|
||||
}
|
||||
66
src/renderer/src/hooks/use-game-card.ts
Normal file
66
src/renderer/src/hooks/use-game-card.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useFormat } from "./use-format";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { LibraryGame } from "@types";
|
||||
|
||||
export function useGameCard(
|
||||
game: LibraryGame,
|
||||
onContextMenu: (game: LibraryGame, position: { x: number; y: number }) => void
|
||||
) {
|
||||
const { t } = useTranslation("library");
|
||||
const { numberFormatter } = useFormat();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInMilliseconds = 0, isShort = false) => {
|
||||
const minutes = playTimeInMilliseconds / 60000;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t(isShort ? "amount_minutes_short" : "amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
const hoursKey = isShort ? "amount_hours_short" : "amount_hours";
|
||||
const hoursAmount = isShort
|
||||
? Math.floor(hours)
|
||||
: numberFormatter.format(hours);
|
||||
|
||||
return t(hoursKey, { amount: hoursAmount });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
navigate(buildGameDetailsPath(game));
|
||||
}, [navigate, game]);
|
||||
|
||||
const handleContextMenuClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(game, { x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[game, onContextMenu]
|
||||
);
|
||||
|
||||
const handleMenuButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onContextMenu(game, { x: rect.right, y: rect.bottom });
|
||||
},
|
||||
[game, onContextMenu]
|
||||
);
|
||||
|
||||
return {
|
||||
formatPlayTime,
|
||||
handleCardClick,
|
||||
handleContextMenuClick,
|
||||
handleMenuButtonClick,
|
||||
};
|
||||
}
|
||||
102
src/renderer/src/hooks/use-hls-video.ts
Normal file
102
src/renderer/src/hooks/use-hls-video.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
interface UseHlsVideoOptions {
|
||||
videoSrc: string | undefined;
|
||||
videoType: string | undefined;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export function useHlsVideo(
|
||||
videoRef: React.RefObject<HTMLVideoElement>,
|
||||
{ videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions
|
||||
) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !videoSrc) return;
|
||||
|
||||
const isHls = videoType === "application/x-mpegURL";
|
||||
|
||||
if (!isHls) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
});
|
||||
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.loadSource(videoSrc);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
logger.warn("Failed to autoplay HLS video:", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
logger.error("HLS network error, trying to recover");
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
logger.error("HLS media error, trying to recover");
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
logger.error("HLS fatal error, destroying instance");
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
};
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = videoSrc;
|
||||
video.load();
|
||||
if (autoplay) {
|
||||
video.play().catch((err) => {
|
||||
logger.warn("Failed to autoplay HLS video:", err);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.src = "";
|
||||
};
|
||||
} else {
|
||||
logger.warn("HLS playback is not supported in this browser");
|
||||
return undefined;
|
||||
}
|
||||
}, [videoRef, videoSrc, videoType, autoplay, muted, loop]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (muted !== undefined) {
|
||||
video.muted = muted;
|
||||
}
|
||||
if (loop !== undefined) {
|
||||
video.loop = loop;
|
||||
}
|
||||
}, [videoRef, muted, loop]);
|
||||
|
||||
return hlsRef.current;
|
||||
}
|
||||
89
src/renderer/src/hooks/use-search-history.ts
Normal file
89
src/renderer/src/hooks/use-search-history.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
|
||||
export interface SearchHistoryEntry {
|
||||
query: string;
|
||||
timestamp: number;
|
||||
context: "library" | "catalogue";
|
||||
}
|
||||
|
||||
const LEVELDB_KEY = "searchHistory";
|
||||
const MAX_HISTORY_ENTRIES = 15;
|
||||
|
||||
export function useSearchHistory() {
|
||||
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadHistory = async () => {
|
||||
if (isInitialized.current) return;
|
||||
isInitialized.current = true;
|
||||
|
||||
try {
|
||||
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
|
||||
| SearchHistoryEntry[]
|
||||
| null;
|
||||
|
||||
if (data) {
|
||||
setHistory(data);
|
||||
}
|
||||
} catch {
|
||||
setHistory([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
const addToHistory = useCallback(
|
||||
(query: string, context: "library" | "catalogue") => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
const newEntry: SearchHistoryEntry = {
|
||||
query: query.trim(),
|
||||
timestamp: Date.now(),
|
||||
context,
|
||||
};
|
||||
|
||||
setHistory((prev) => {
|
||||
const filtered = prev.filter(
|
||||
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
|
||||
);
|
||||
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
|
||||
levelDBService.put(LEVELDB_KEY, updated, null, "json");
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeFromHistory = useCallback((query: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = prev.filter((entry) => entry.query !== query);
|
||||
levelDBService.put(LEVELDB_KEY, updated, null, "json");
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
setHistory([]);
|
||||
levelDBService.del(LEVELDB_KEY, null);
|
||||
}, []);
|
||||
|
||||
const getRecentHistory = useCallback(
|
||||
(context: "library" | "catalogue", limit: number = 3) => {
|
||||
return history
|
||||
.filter((entry) => entry.context === context)
|
||||
.slice(0, limit);
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
return {
|
||||
history,
|
||||
addToHistory,
|
||||
removeFromHistory,
|
||||
clearHistory,
|
||||
getRecentHistory,
|
||||
};
|
||||
}
|
||||
163
src/renderer/src/hooks/use-search-suggestions.ts
Normal file
163
src/renderer/src/hooks/use-search-suggestions.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppSelector } from "./redux";
|
||||
import { debounce } from "lodash-es";
|
||||
import { logger } from "@renderer/logger";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
export interface SearchSuggestion {
|
||||
title: string;
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
iconUrl: string | null;
|
||||
source: "library" | "catalogue";
|
||||
}
|
||||
|
||||
export function useSearchSuggestions(
|
||||
query: string,
|
||||
isOnLibraryPage: boolean,
|
||||
enabled: boolean = true
|
||||
) {
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const library = useAppSelector((state) => state.library.value);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
|
||||
|
||||
const getLibrarySuggestions = useCallback(
|
||||
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
|
||||
if (!searchQuery.trim()) return [];
|
||||
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
const matches: SearchSuggestion[] = [];
|
||||
|
||||
for (const game of library) {
|
||||
if (matches.length >= limit) break;
|
||||
|
||||
const titleLower = game.title.toLowerCase();
|
||||
let queryIndex = 0;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < titleLower.length && queryIndex < queryLower.length;
|
||||
i++
|
||||
) {
|
||||
if (titleLower[i] === queryLower[queryIndex]) {
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (queryIndex === queryLower.length) {
|
||||
matches.push({
|
||||
title: game.title,
|
||||
objectId: game.objectId,
|
||||
shop: game.shop,
|
||||
iconUrl: game.iconUrl,
|
||||
source: "library",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
},
|
||||
[library]
|
||||
);
|
||||
|
||||
const fetchCatalogueSuggestions = useCallback(
|
||||
async (searchQuery: string, limit: number = 3) => {
|
||||
if (!searchQuery.trim() || searchQuery.length < 2) {
|
||||
setSuggestions([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
|
||||
const cachedResults = cacheRef.current.get(cacheKey);
|
||||
|
||||
if (cachedResults) {
|
||||
setSuggestions(cachedResults);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await window.electron.hydraApi.get<
|
||||
{
|
||||
title: string;
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
iconUrl: string | null;
|
||||
}[]
|
||||
>("/catalogue/search/suggestions", {
|
||||
params: {
|
||||
query: searchQuery,
|
||||
limit,
|
||||
},
|
||||
needsAuth: false,
|
||||
});
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const catalogueSuggestions: SearchSuggestion[] = response.map(
|
||||
(item) => ({
|
||||
...item,
|
||||
source: "catalogue" as const,
|
||||
})
|
||||
);
|
||||
|
||||
cacheRef.current.set(cacheKey, catalogueSuggestions);
|
||||
setSuggestions(catalogueSuggestions);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setSuggestions([]);
|
||||
logger.error("Failed to fetch catalogue suggestions", error);
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const debouncedFetchCatalogue = useRef(
|
||||
debounce(fetchCatalogueSuggestions, 300)
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current?.abort();
|
||||
debouncedFetchCatalogue.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOnLibraryPage) {
|
||||
const librarySuggestions = getLibrarySuggestions(query, 3);
|
||||
setSuggestions(librarySuggestions);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
debouncedFetchCatalogue(query, 3);
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedFetchCatalogue.cancel();
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
isOnLibraryPage,
|
||||
enabled,
|
||||
getLibrarySuggestions,
|
||||
debouncedFetchCatalogue,
|
||||
]);
|
||||
|
||||
return { suggestions, isLoading };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import resources from "@locales";
|
||||
|
||||
import { logger } from "./logger";
|
||||
import { addCookieInterceptor } from "./cookies";
|
||||
import { levelDBService } from "./services/leveldb.service";
|
||||
import Catalogue from "./pages/catalogue/catalogue";
|
||||
import Home from "./pages/home/home";
|
||||
import Downloads from "./pages/downloads/downloads";
|
||||
@@ -29,6 +30,7 @@ import Settings from "./pages/settings/settings";
|
||||
import Profile from "./pages/profile/profile";
|
||||
import Achievements from "./pages/achievements/achievements";
|
||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||
import Library from "./pages/library/library";
|
||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||
|
||||
console.log = logger.log;
|
||||
@@ -47,7 +49,11 @@ i18n
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
const userPreferences = await window.electron.getUserPreferences();
|
||||
const userPreferences = (await levelDBService.get(
|
||||
"userPreferences",
|
||||
null,
|
||||
"json"
|
||||
)) as { language?: string } | null;
|
||||
|
||||
if (userPreferences?.language) {
|
||||
i18n.changeLanguage(userPreferences.language);
|
||||
@@ -64,6 +70,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/catalogue" element={<Catalogue />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
} from "@types";
|
||||
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
|
||||
import {
|
||||
injectCustomCss,
|
||||
removeCustomCss,
|
||||
getAchievementSoundUrl,
|
||||
getAchievementSoundVolume,
|
||||
} from "@renderer/helpers";
|
||||
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
import app from "../../../app.scss?inline";
|
||||
import styles from "../../../components/achievements/notification/achievement-notification.scss?inline";
|
||||
import root from "react-shadow";
|
||||
@@ -33,9 +38,11 @@ export function AchievementNotification() {
|
||||
|
||||
const [shadowRootRef, setShadowRootRef] = useState<HTMLElement | null>(null);
|
||||
|
||||
const playAudio = useCallback(() => {
|
||||
const audio = new Audio(achievementSound);
|
||||
audio.volume = 0.1;
|
||||
const playAudio = useCallback(async () => {
|
||||
const soundUrl = await getAchievementSoundUrl();
|
||||
const volume = await getAchievementSoundVolume();
|
||||
const audio = new Audio(soundUrl);
|
||||
audio.volume = volume;
|
||||
audio.play();
|
||||
}, []);
|
||||
|
||||
@@ -138,7 +145,11 @@ export function AchievementNotification() {
|
||||
|
||||
const loadAndApplyTheme = useCallback(async () => {
|
||||
if (!shadowRootRef) return;
|
||||
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) {
|
||||
injectCustomCss(activeTheme.code, shadowRootRef);
|
||||
} else {
|
||||
|
||||
@@ -4,158 +4,512 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
margin-inline: calc(globals.$spacing-unit * 3);
|
||||
padding-block: calc(globals.$spacing-unit * 3);
|
||||
|
||||
&__details-with-article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
align-self: flex-start;
|
||||
cursor: pointer;
|
||||
&--queued {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
padding-top: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit);
|
||||
|
||||
&-divider {
|
||||
&-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
flex: 1;
|
||||
background-color: globals.$border-color;
|
||||
height: 1px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
&-count {
|
||||
font-weight: 400;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: globals.$spacing-unit;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: globals.$body-color;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__downloads {
|
||||
&--hero {
|
||||
width: 100%;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: globals.$spacing-unit;
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__item {
|
||||
&__hero-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: globals.$background-color;
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
border: solid 1px globals.$border-color;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 0px 5px 0px #000000;
|
||||
transition: all ease 0.2s;
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
max-height: 140px;
|
||||
position: relative;
|
||||
height: 120%;
|
||||
z-index: 0;
|
||||
|
||||
&--hydra {
|
||||
box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15);
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 20%;
|
||||
}
|
||||
}
|
||||
&__cover {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
height: auto;
|
||||
border-right: solid 1px globals.$border-color;
|
||||
// PLEASE FIX THE COLORS
|
||||
&__hero-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.3) 0%,
|
||||
rgb(5, 5, 5) 70%,
|
||||
rgb(26, 26, 26) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&__hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: globals.$spacing-unit;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-backdrop {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.8) 5%,
|
||||
transparent 100%
|
||||
);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
&__right-content {
|
||||
display: flex;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
flex: 1;
|
||||
gap: globals.$spacing-unit;
|
||||
background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%);
|
||||
}
|
||||
|
||||
&__details {
|
||||
padding: calc(globals.$spacing-unit * 4);
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-size: 14px;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
&__hero-logo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
|
||||
&-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 180px;
|
||||
max-height: 60px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
max-width: 220px;
|
||||
max-height: 75px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 900px) {
|
||||
max-width: 280px;
|
||||
max-height: 95px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1200px) {
|
||||
max-width: 340px;
|
||||
max-height: 115px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1500px) {
|
||||
max-width: 400px;
|
||||
max-height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 900px) {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1200px) {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1500px) {
|
||||
font-size: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
&__hero-action-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
margin-top: calc(globals.$spacing-unit * 4);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__hero-buttons {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__glass-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__progress-info-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__progress-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&--bar {
|
||||
margin-top: calc(globals.$spacing-unit);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__progress-percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
align-self: flex-end;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__hero-stats {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 4);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(26, 26, 26, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
margin-top: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__stats-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
min-width: 200px;
|
||||
padding-right: calc(globals.$spacing-unit * 2);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__speed-chart {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__speed-chart-canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
&__stat-item {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
|
||||
svg {
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__stat-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__simple-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__simple-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__simple-thumbnail {
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid globals.$border-color;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__simple-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 1);
|
||||
}
|
||||
|
||||
&__simple-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__simple-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__simple-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
font-size: 13px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__simple-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__simple-extracting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__simple-seeding {
|
||||
color: #4ade80;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__simple-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__simple-progress-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__simple-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
}
|
||||
|
||||
&__simple-menu-btn {
|
||||
padding: calc(globals.$spacing-unit);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
&__hydra-gradient {
|
||||
background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%);
|
||||
box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15);
|
||||
&__progress-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
z-index: 1;
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
|
||||
&--small {
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user