Compare commits

...

230 Commits

Author SHA1 Message Date
Chubby Granny Chaser
e578047929 feat: add translation key for Hydra Wrapped 2025 in multiple languages and implement sidebar route with marquee effect
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-12-08 15:22:05 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
Chubby Granny Chaser
e872b2ea8a chore: bump version to 3.7.5
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-30 06:26:43 +00:00
Chubby Granny Chaser
dd7c84b433 Merge pull request #1881 from hydralauncher/fix/downloads-ui
fix: auto-resuming download isnt working after restart
2025-11-30 06:26:08 +00:00
Chubby Granny Chaser
1546da29cf Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra into fix/downloads-ui 2025-11-30 06:25:39 +00:00
Chubby Granny Chaser
a89b0bb2a8 style: refactor download group component to optimize download state management and improve UI responsiveness 2025-11-30 06:25:17 +00:00
Moyasee
9bdb216e0f fix: deleted comment 2025-11-30 08:23:49 +02:00
Moyasee
9779aed8c1 fix: auto-resuming download isnt working after restart 2025-11-30 08:05:45 +02:00
Chubby Granny Chaser
058a148c7f style: add button styling and refactor logo click handling in download group for improved accessibility and user experience 2025-11-30 05:44:18 +00:00
Chubby Granny Chaser
16e3d52508 style: enhance download group styling for improved layout, responsiveness, and user interaction 2025-11-30 05:39:01 +00:00
Chubby Granny Chaser
7e0002cf95 style: format imports in download-group.tsx for improved readability 2025-11-30 05:14:48 +00:00
Chubby Granny Chaser
bf8b3ca836 style: update download group layout and styling for improved responsiveness 2025-11-30 05:14:26 +00:00
Moyasee
77e376e742 fix: peak spead not working 2025-11-30 07:13:12 +02:00
Chubby Granny Chaser
bd28b202c4 Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra 2025-11-30 05:06:59 +00:00
Moyasee
153b954e78 fix: progress bar, context menu, repacks modal, responsiveness and styling fix 2025-11-30 07:05:19 +02:00
Chubby Granny Chaser
a9e63730be Merge pull request #1880 from hydralauncher/fix/fixing-hls-videos
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Fix/fixing hls videos
2025-11-30 03:45:10 +00:00
Chubby Granny Chaser
316480930d Merge branch 'main' into fix/fixing-hls-videos 2025-11-30 03:45:00 +00:00
Chubby Granny Chaser
0b5c9acaaa Merge pull request #1861 from iam-sahil/Downloads-UI
feat: enhance download page UI with improved layout and styling for the download cards.
2025-11-30 03:44:33 +00:00
Chubby Granny Chaser
814a2da05c Merge branch 'main' into Downloads-UI 2025-11-30 03:44:10 +00:00
Chubby Granny Chaser
0ad1ebd6a2 fix: fixing hls videos 2025-11-30 03:43:22 +00:00
Chubby Granny Chaser
e9de8264e2 fix: fixing hls videos 2025-11-30 03:41:41 +00:00
Chubby Granny Chaser
b135087ffe fix: fixing hls videos 2025-11-30 03:38:23 +00:00
Chubby Granny Chaser
b4a1af78a6 Merge pull request #1877 from egionCode/main
adding sorting for recently played based on last time the game was op…
2025-11-30 03:21:08 +00:00
Chubby Granny Chaser
ede5bb0c23 Merge branch 'main' into main 2025-11-30 03:20:03 +00:00
Chubby Granny Chaser
9a27875cd8 Merge pull request #1866 from hydralauncher/feat/search-autosuggest
Feat: search history and auto-suggest
2025-11-30 03:19:57 +00:00
Chubby Granny Chaser
cf20a942ae Merge branch 'main' into main 2025-11-30 03:17:07 +00:00
Chubby Granny Chaser
256d829a60 feat: adding translations 2025-11-30 03:15:27 +00:00
Chubby Granny Chaser
8cb18578e0 Merge branch 'main' into feat/search-autosuggest 2025-11-30 03:06:00 +00:00
Chubby Granny Chaser
62950297e0 Merge pull request #1874 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a
chore(deps): bump tar from 7.5.1 to 7.5.2 in the npm_and_yarn group across 1 directory
2025-11-30 03:05:46 +00:00
Chubby Granny Chaser
3eecc42430 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a 2025-11-30 03:04:37 +00:00
Chubby Granny Chaser
f6edb45628 Merge pull request #1875 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd
chore(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory
2025-11-30 03:04:30 +00:00
Chubby Granny Chaser
de8797bea6 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd 2025-11-30 03:04:23 +00:00
Chubby Granny Chaser
828f82f647 Merge pull request #1879 from hydralauncher/feat/adding-level-generic-interface
Feat/adding level generic interface
2025-11-30 03:04:12 +00:00
Moyasee
bb22d9c4dd ci: migration of search history from localStorage to LevelDB and highlighting fix 2025-11-29 05:30:10 +02:00
Moyasee
559bb45acc Merge branch 'feat/adding-level-generic-interface' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-29 05:10:54 +02:00
Chubby Granny Chaser
8e3a932aa4 fix: fixing code quality issues 2025-11-29 02:40:52 +00:00
Chubby Granny Chaser
1fc87f93b7 fix: fixing code quality issues 2025-11-29 02:39:21 +00:00
Chubby Granny Chaser
f28c867479 feat: adding level generic interface 2025-11-29 02:25:29 +00:00
Chubby Granny Chaser
928acc2765 feat: adding level generic interface 2025-11-29 02:22:07 +00:00
Chubby Granny Chaser
140718764d feat: adding level generic interface 2025-11-29 02:19:41 +00:00
Chubby Granny Chaser
f41128c4c8 feat: adding level generic interface 2025-11-29 02:19:21 +00:00
Victor
e176e624be adding sorting for recently played based on last time the game was opened 2025-11-27 11:23:50 -03:00
Chubby Granny Chaser
59b3fb5317 Merge branch 'release/v3.7.4' of https://github.com/hydralauncher/hydra 2025-11-26 15:38:23 +00:00
Chubby Granny Chaser
d205f2b391 fix: hotfixing video player
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-25 23:55:13 +00:00
Chubby Granny Chaser
82ab889dad fix: hotfixing video player 2025-11-25 23:54:36 +00:00
dependabot[bot]
4e92e794be chore(deps): bump js-yaml in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:31 +00:00
dependabot[bot]
de0dbcac35 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.1 to 7.5.2
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.1...v7.5.2)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:16 +00:00
Chubby Granny Chaser
c3880ce181 fix: test 2025-11-23 20:34:05 +00:00
Zamitto
5e86ad4d7e Merge pull request #1872 from epcgrs/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
fix: Update icon image broken in README.md
2025-11-23 13:30:55 -03:00
Machine Zero
e2fb59ed8d fix: Update icon image broken in README.md
Fix the image with broken link in readme.md
2025-11-22 19:34:55 -03:00
Moyasee
07d5a5b3f3 Merge branch 'feat/search-autosuggest' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-22 07:31:15 +02:00
Moyasee
a1117c8269 feat: improving suggestion dropdown design 2025-11-22 07:26:48 +02:00
Chubby Granny Chaser
dc04cff378 Merge branch 'main' into feat/search-autosuggest 2025-11-19 09:42:17 +00:00
Zamitto
6df34e7f3c chore: update hydra docs link on PR template
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-16 13:45:32 -03:00
Zamitto
2773fa7b3c Merge pull request #1862 from flyingcakes85/patch-1
[fix] ci: fix version name in aur commit message
2025-11-16 13:38:05 -03:00
Moyasee
093a9f251e feat: selective history removal 2025-11-15 21:10:33 +02:00
Moyasee
9979e92d8f fix: reverted detach mode for devtools window 2025-11-15 21:05:51 +02:00
Moyasee
8cd613e3b6 fix: removed unused variables 2025-11-15 21:04:08 +02:00
Moyasee
28bf7b8764 feat: search history and suggestions 2025-11-15 21:02:28 +02:00
ctrlcat0x
5bffaf17fa fix: adjust padding for completed downloads and improve conditional rendering in download actions 2025-11-15 13:47:50 +05:30
ctrlcat0x
cc38be4383 Fixed linter and sonarcloud errors, refactored some functions and fixed UI padding issues with certain themes. 2025-11-15 11:31:39 +05:30
ctrlcat0x
0b70a28c08 feat: enhance download group UI with speed chart improvements and gradient progress bar 2025-11-15 01:16:23 +05:30
ctrlcat0x
3ff50a9932 feat: update download group UI with hero section and speed chart integration 2025-11-15 00:44:54 +05:30
Snehit Sah
be3ce6e2db ci: fix version name in aur commit
omit extra 'v' in commit message
2025-11-14 20:20:26 +05:30
ctrlcat0x
83fbf20383 feat: enhance download page UI with improved layout and styling for cards 2025-11-14 20:02:10 +05:30
Chubby Granny Chaser
c600a4a46f fix: fixing achievements on larger view
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-13 15:22:03 +00:00
Chubby Granny Chaser
d14951f25c Merge pull request #1857 from hydralauncher/fix/use-local-achievement-cache
fix: achievements on library page
2025-11-13 15:12:40 +00:00
Chubby Granny Chaser
d6b38771a8 Merge branch 'main' into fix/use-local-achievement-cache 2025-11-13 09:54:30 +00:00
Chubby Granny Chaser
8400edd000 Merge pull request #1858 from quirxsama/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
[translation] Added Turkish translations for new keys
2025-11-13 09:54:08 +00:00
Chubby Granny Chaser
6e3c5cac7e Merge branch 'main' into fix/use-local-achievement-cache 2025-11-13 09:53:28 +00:00
Chubby Granny Chaser
4f2d6f3302 Merge branch 'main' into main 2025-11-13 09:53:23 +00:00
Chubby Granny Chaser
72c3219fc0 Merge pull request #1859 from Stormm232/main
3.7.4 - Hungarian  Translation
2025-11-13 09:53:09 +00:00
Kiwo.2
048c56d670 Prettified it 2025-11-12 19:35:57 +01:00
Kiwo.2
43505a281f Merge branch 'main' of https://github.com/Stormm232/hydra 2025-11-12 19:11:50 +01:00
Kiwo.2
c380e5f5a0 Fixes & 3.7.4's new lines 2025-11-12 19:11:13 +01:00
Zamitto
5c32e61569 Merge branch 'test' into fix/use-local-achievement-cache 2025-11-12 14:53:24 -03:00
Zamitto
f594cd298a fix: performance 2025-11-12 14:52:38 -03:00
Zamitto
20c0d3174b test 2025-11-12 14:37:44 -03:00
Kaan
bcd6db24c9 Add turkish translates for new keys
added keys:
 - sidebar.library
  - sidebar.playable_button_title
  - sidebar.add_custom_game_tooltip
  - sidebar.show_playable_only_tooltip
  - sidebar.custom_game_modal
  - sidebar.custom_game_modal_description
  - sidebar.custom_game_modal_executable_path
  - sidebar.custom_game_modal_select_executable
  - sidebar.custom_game_modal_title
  - sidebar.custom_game_modal_enter_title
  - sidebar.custom_game_modal_browse
  - sidebar.custom_game_modal_cancel
  - sidebar.custom_game_modal_add
  - sidebar.custom_game_modal_adding
  - sidebar.custom_game_modal_success
  - sidebar.custom_game_modal_failed
  - sidebar.custom_game_modal_executable
  - sidebar.edit_game_modal
  - sidebar.edit_game_modal_description
  - sidebar.edit_game_modal_title
  - sidebar.edit_game_modal_enter_title
  - sidebar.edit_game_modal_image
  - sidebar.edit_game_modal_select_image
  - sidebar.edit_game_modal_browse
  - sidebar.edit_game_modal_image_preview
  - sidebar.edit_game_modal_icon
  - sidebar.edit_game_modal_select_icon
  - sidebar.edit_game_modal_icon_preview
  - sidebar.edit_game_modal_logo
  - sidebar.edit_game_modal_select_logo
  - sidebar.edit_game_modal_logo_preview
  - sidebar.edit_game_modal_hero
  - sidebar.edit_game_modal_select_hero
  - sidebar.edit_game_modal_hero_preview
  - sidebar.edit_game_modal_cancel
  - sidebar.edit_game_modal_update
  - sidebar.edit_game_modal_updating
  - sidebar.edit_game_modal_fill_required
  - sidebar.edit_game_modal_success
  - sidebar.edit_game_modal_failed
  - sidebar.edit_game_modal_image_filter
  - sidebar.edit_game_modal_icon_resolution
  - sidebar.edit_game_modal_logo_resolution
  - sidebar.edit_game_modal_hero_resolution
  - sidebar.edit_game_modal_assets
  - sidebar.edit_game_modal_drop_icon_image_here
  - sidebar.edit_game_modal_drop_logo_image_here
  - sidebar.edit_game_modal_drop_hero_image_here
  - sidebar.edit_game_modal_drop_to_replace_icon
  - sidebar.edit_game_modal_drop_to_replace_logo
  - sidebar.edit_game_modal_drop_to_replace_hero
  - sidebar.install_decky_plugin
  - sidebar.update_decky_plugin
  - sidebar.decky_plugin_installed_version
  - sidebar.install_decky_plugin_title
  - sidebar.install_decky_plugin_message
  - sidebar.update_decky_plugin_title
  - sidebar.update_decky_plugin_message
  - sidebar.decky_plugin_installed
  - sidebar.decky_plugin_installation_failed
  - sidebar.decky_plugin_installation_error
  - sidebar.confirm
  - sidebar.cancel
  - header.search_library
  - header.library
  - game_details.already_in_library
  - game_details.create_shortcut_simple
  - game_details.properties
  - game_details.new_download_option
  - game_details.add_to_favorites
  - game_details.remove_from_favorites
  - game_details.failed_update_favorites
  - game_details.game_removed_from_library
  - game_details.failed_remove_from_library
  - game_details.files_removed_success
  - game_details.failed_remove_files
  - game_details.rating_count
  - game_details.show_more
  - game_details.show_less
  - game_details.reviews
  - game_details.review_played_for
  - game_details.leave_a_review
  - game_details.write_review_placeholder
  - game_details.sort_newest
  - game_details.no_reviews_yet
  - game_details.be_first_to_review
  - game_details.sort_oldest
  - game_details.sort_highest_score
  - game_details.sort_lowest_score
  - game_details.sort_most_voted
  - game_details.rating
  - game_details.rating_stats
  - game_details.rating_very_negative
  - game_details.rating_negative
  - game_details.rating_neutral
  - game_details.rating_positive
  - game_details.rating_very_positive
  - game_details.submit_review
  - game_details.submitting
  - game_details.review_submitted_successfully
  - game_details.review_submission_failed
  - game_details.review_cannot_be_empty
  - game_details.review_deleted_successfully
  - game_details.review_deletion_failed
  - game_details.loading_reviews
  - game_details.loading_more_reviews
  - game_details.load_more_reviews
  - game_details.you_seemed_to_enjoy_this_game
  - game_details.would_you_recommend_this_game
  - game_details.yes
  - game_details.maybe_later
  - game_details.backup_failed
  - game_details.update_playtime_title
  - game_details.update_playtime_description
  - game_details.update_playtime
  - game_details.update_playtime_success
  - game_details.update_playtime_error
  - game_details.update_game_playtime
  - game_details.manual_playtime_warning
  - game_details.manual_playtime_tooltip
  - game_details.game_removed_from_pinned
  - game_details.game_added_to_pinned
  - game_details.artifact_renamed
  - game_details.rename_artifact
  - game_details.rename_artifact_description
  - game_details.artifact_name_label
  - game_details.artifact_name_placeholder
  - game_details.save_changes
  - game_details.required_field
  - game_details.max_length_field
  - game_details.freeze_backup
  - game_details.unfreeze_backup
  - game_details.backup_frozen
  - game_details.backup_unfrozen
  - game_details.backup_freeze_failed
  - game_details.backup_freeze_failed_description
  - game_details.edit_game_modal_button
  - game_details.game_details
  - game_details.currency_symbol
  - game_details.currency_country
  - game_details.prices
  - game_details.no_prices_found
  - game_details.view_all_prices
  - game_details.retail_price
  - game_details.keyshop_price
  - game_details.historical_retail
  - game_details.historical_keyshop
  - game_details.language
  - game_details.caption
  - game_details.audio
  - game_details.filter_by_source
  - game_details.no_repacks_found
  - game_details.delete_review
  - game_details.remove_review
  - game_details.delete_review_modal_title
  - game_details.delete_review_modal_description
  - game_details.delete_review_modal_delete_button
  - game_details.delete_review_modal_cancel_button
  - game_details.vote_failed
  - game_details.show_original
  - game_details.show_translation
  - game_details.show_original_translated_from
  - game_details.hide_original
  - game_details.review_from_blocked_user
  - game_details.show
  - game_details.hide
  - settings.adding
  - settings.failed_add_download_source
  - settings.download_source_already_exists
  - settings.download_source_pending_matching
  - settings.download_source_matched
  - settings.download_source_matching
  - settings.download_source_failed
  - settings.download_source_no_information
  - settings.removed_all_download_sources
  - settings.download_sources_synced_successfully
  - settings.importing
  - settings.hydra_cloud
  - settings.debrid
  - settings.debrid_description
  - settings.enable_steam_achievements
  - settings.achievement_sound_volume
  - settings.select_achievement_sound
  - settings.change_achievement_sound
  - settings.remove_achievement_sound
  - settings.preview_sound
  - settings.select
  - settings.preview
  - settings.remove
  - settings.no_sound_file_selected
  - settings.autoplay_trailers_on_game_page
  - settings.hide_to_tray_on_game_start
  - game_card.calculating
  - user_profile.amount_hours_short
  - user_profile.amount_minutes_short
  - user_profile.pinned
  - user_profile.sort_by
  - user_profile.achievements_earned
  - user_profile.played_recently
  - user_profile.playtime
  - user_profile.manual_playtime_tooltip
  - user_profile.error_adding_friend
  - user_profile.friend_code_length_error
  - user_profile.game_removed_from_pinned
  - user_profile.game_added_to_pinned
  - user_profile.karma
  - user_profile.karma_count
  - user_profile.karma_description
  - user_profile.user_reviews
  - user_profile.delete_review
  - user_profile.loading_reviews
  - library.library
  - library.play
  - library.download
  - library.downloading
  - library.game
  - library.games
  - library.grid_view
  - library.compact_view
  - library.large_view
  - library.no_games_title
  - library.no_games_description
  - library.amount_hours
  - library.amount_minutes
  - library.amount_hours_short
  - library.amount_minutes_short
  - library.manual_playtime_tooltip
  - library.all_games
  - library.recently_played
  - library.favorites
2025-11-12 18:46:18 +03:00
Zamitto
c2216bbf95 feat: use jpg for system notifications 2025-11-12 08:17:53 -03:00
Zamitto
f84917a00b feat: get user static image on notifications 2025-11-12 08:07:51 -03:00
Zamitto
94ebf94abc fix: use local achievement cache for unlocked achievement count 2025-11-12 07:21:28 -03:00
Zamitto
cd3fa10bf7 chore: fix version code
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-11 18:27:53 -03:00
Zamitto
a57cc83076 Merge branch 'release/v3.7.5' 2025-11-11 18:17:53 -03:00
Zamitto
c75a6ad439 fix: using achievement count data from api 2025-11-11 18:15:26 -03:00
Moyase
05d68fa23b Merge pull request #1856 from hydralauncher/fix/library-game-card
fix: custom assets not being showed in library page
2025-11-11 22:10:24 +02:00
Moyasee
527a65e9bc feat: remembering the view user left the library and restoring it on opening library again 2025-11-11 22:07:42 +02:00
Moyasee
fe6bb5763d fix: deleting game from context menu doesnt work in library 2025-11-11 22:03:33 +02:00
Moyasee
002dff098c fix: custom assets not being showed in library page 2025-11-11 21:50:48 +02:00
Chubby Granny Chaser
436d1b74be ci: fixing format
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-11 18:35:18 +00:00
Chubby Granny Chaser
b89de065fe ci: fixing format 2025-11-11 18:33:28 +00:00
Chubby Granny Chaser
7fcdab07cb Merge pull request #1842 from hydralauncher/feat/displaying-new-game-update
Feat: displaying new game update
2025-11-11 18:23:42 +00:00
Chubby Granny Chaser
aebf6d1cae feat: adding translations for new label 2025-11-11 18:22:39 +00:00
Moyase
a2148dd1ef Merge branch 'main' into feat/displaying-new-game-update 2025-11-11 20:02:32 +02:00
Moyasee
8dc5be1bdf reverting changes 2025-11-11 20:01:28 +02:00
Moyasee
133168c6c7 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-11 16:32:37 +02:00
Moyasee
d59b96f446 fix: typescript error 2025-11-11 16:31:53 +02:00
Moyasee
a1eef4eab6 feat: check updates for installed games 2025-11-11 16:30:23 +02:00
Chubby Granny Chaser
25103e5eb7 ci: updating ci
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-11 09:09:03 +00:00
Moyase
9cf0ef4b62 Merge branch 'main' into feat/displaying-new-game-update 2025-11-11 01:32:50 +02:00
Moyasee
1521d7c058 reverting changes 2025-11-11 01:29:04 +02:00
Moyasee
14eb0f8172 reverting changes 2025-11-11 01:27:24 +02:00
Moyasee
860030a510 fix: merging conflict 2025-11-11 01:23:37 +02:00
Moyasee
f0e4d241f9 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-11 01:15:13 +02:00
Moyasee
44b24ab63d feat: checking updates only for games with executables 2025-11-11 01:14:27 +02:00
Chubby Granny Chaser
7c1adb70ea fix: fixing lint 2025-11-10 23:07:52 +00:00
Chubby Granny Chaser
9854ed2f53 Merge pull request #1852 from hydralauncher/feat/custom-achievement-sound
Feat: custom achievement sound and volume changing
2025-11-10 22:59:34 +00:00
Chubby Granny Chaser
b8647a3300 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/custom-achievement-sound 2025-11-10 22:57:59 +00:00
Chubby Granny Chaser
95894484f1 feat: adding slider to achievement sound 2025-11-10 22:57:35 +00:00
Chubby Granny Chaser
6fc5a70722 feat: adding slider to achievement sound 2025-11-10 22:55:49 +00:00
Chubby Granny Chaser
399669a94c feat: adding slider to achievement sound 2025-11-10 22:55:17 +00:00
Chubby Granny Chaser
77b2fc3946 Merge pull request #1848 from hydralauncher/fix/library-ui
feat: library ui changes and searchbar removal
2025-11-10 22:55:02 +00:00
Chubby Granny Chaser
3472e90858 Merge branch 'main' into fix/library-ui 2025-11-10 22:53:52 +00:00
Chubby Granny Chaser
d80daa59d0 Merge branch 'main' into feat/custom-achievement-sound 2025-11-10 22:21:51 +00:00
Chubby Granny Chaser
46df34e8a5 feat: improving library 2025-11-10 22:20:44 +00:00
Chubby Granny Chaser
272b047ded Merge pull request #1844 from expload233/main
Add new translation for Chinese
2025-11-10 21:51:44 +00:00
Chubby Granny Chaser
dfba38aeed Merge branch 'main' into main 2025-11-10 21:51:20 +00:00
Moyasee
d54ff9a949 fix: eslint issues 2025-11-09 15:34:24 +02:00
Moyasee
e272470a7b feat: using theme name for folder instead themeid 2025-11-09 15:28:52 +02:00
Moyasee
53bc3551e1 Merge branch 'feat/custom-achievement-sound' of https://github.com/hydralauncher/hydra into feat/custom-achievement-sound 2025-11-09 04:20:48 +02:00
Moyasee
3daf28c882 fix: handling exception and ESLint issues 2025-11-09 04:19:52 +02:00
Moyase
e128dad4dd Merge branch 'main' into feat/custom-achievement-sound 2025-11-09 04:14:31 +02:00
Moyasee
65e2bb38a0 ci: formatting 2025-11-08 19:26:13 +02:00
Moyasee
5f09321728 Merge branch 'fix/library-ui' of https://github.com/hydralauncher/hydra into fix/library-ui 2025-11-08 19:25:29 +02:00
Moyasee
011559b499 fix: removed VirtualList component from large view 2025-11-08 19:24:43 +02:00
Moyasee
482d9b2f96 fix: ensure consistent custom sound detection across main and renderer processes 2025-11-08 15:14:12 +02:00
Moyase
c890b0fd56 Merge branch 'main' into fix/library-ui 2025-11-08 14:52:35 +02:00
Moyasee
cf48627a8d fix: extracter ternary operation 2025-11-08 14:51:10 +02:00
Moyasee
196413ee28 fix: duplicated lines 2025-11-08 14:48:42 +02:00
Moyasee
c3a4990a50 ci: performance optimizing in library 2025-11-08 14:28:54 +02:00
Chubby Granny Chaser
fa4c11e458 Merge pull request #1849 from hydralauncher/fix/surprise-me-button
Fix: surprise me button functionality
2025-11-08 08:18:13 +00:00
Chubby Granny Chaser
50b0a82204 feat: improving styles on randomizer button 2025-11-08 08:17:19 +00:00
Moyasee
b6bbf05da6 fix: theme editor layout positioning 2025-11-07 20:12:50 +02:00
Moyasee
154b6271a1 fix: removed unused function 2025-11-07 17:50:47 +02:00
Moyasee
a6cbaf6dc1 feat: custom achievement sound and volume changing) 2025-11-07 17:48:56 +02:00
Moyasee
6e6e0f7bb7 fix: duplicate next suggestion styling removal 2025-11-07 13:35:50 +02:00
Moyasee
893802be55 fix: next suggestion and title not being showed 2025-11-07 13:27:24 +02:00
Moyasee
3bef0c9269 feat: library ui changes and searchbar removal 2025-11-06 18:26:56 +02:00
Moyase
754e9c14b8 Merge pull request #1821 from iam-sahil/feat/library
Feat/library
2025-11-06 17:22:58 +02:00
ctrlcat0x
5e653be4c3 fix: add error logging in handleActionClick for better debugging 2025-11-06 19:11:20 +05:30
ctrlcat0x
cedf7e6e37 style: improve color contrast in various components and update prop types to readonly 2025-11-06 19:03:23 +05:30
Moyase
518a0e1cf4 Merge branch 'main' into feat/library 2025-11-06 14:57:22 +02:00
expload233
5f56a3d517 add translation 2025-11-04 15:47:44 +08:00
expload
2359c4cc5e Merge branch 'hydralauncher:main' into main 2025-11-04 15:31:03 +08:00
expload233
66bb5221c1 fix lint 2025-11-04 15:24:42 +08:00
Chubby Granny Chaser
7fa50dc5a7 feat: adding ws client back 2025-11-03 12:02:52 +00:00
expload233
088feaffc2 add missing translation for zh-CN 2025-11-03 19:02:11 +08:00
expload233
aa6b595b18 Fill in the missing entries 2025-11-03 18:53:02 +08:00
Chubby Granny Chaser
f49fea3032 ci: bump version
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-02 21:17:34 +00:00
Chubby Granny Chaser
595d39986d Merge pull request #1843 from hydralauncher/fix/fixing-datanodes
fix: fixing datanodes
2025-11-02 21:15:53 +00:00
Chubby Granny Chaser
ac01930d68 Merge branch 'main' into fix/fixing-datanodes 2025-11-02 21:14:49 +00:00
Chubby Granny Chaser
37caeb8047 fix: fixing datanodes 2025-11-02 21:13:44 +00:00
Chubby Granny Chaser
7d6eddb17e Merge pull request #1826 from hydralauncher/feat/reviews-in-profile
Feat: Showing User Reviews in profile
2025-11-02 20:46:47 +00:00
Chubby Granny Chaser
20338fa20b Merge branch 'main' into feat/displaying-new-game-update 2025-11-02 20:45:56 +00:00
Chubby Granny Chaser
48775e57fc feat: adding reviews to profile 2025-11-02 20:43:59 +00:00
Chubby Granny Chaser
fdc3fecd6f feat: adding reviews to profile 2025-11-02 20:42:42 +00:00
Chubby Granny Chaser
f0dc7478cf feat: adding reviews to profile 2025-11-02 20:29:16 +00:00
Chubby Granny Chaser
e7a437e839 Merge branch 'main' into feat/library 2025-11-02 20:23:44 +00:00
Chubby Granny Chaser
2e8da53d1a feat: adding infinite scroll 2025-11-02 20:23:12 +00:00
Chubby Granny Chaser
8794fbc742 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/reviews-in-profile 2025-11-02 17:31:06 +00:00
Chubby Granny Chaser
bf387aef3f feat: improving animations 2025-11-02 17:30:45 +00:00
Chubby Granny Chaser
c2a26b9750 Merge pull request #1832 from hydralauncher/feat/playtime-in-reviews
Feat: Playtime showing in review message
2025-11-02 17:29:28 +00:00
Moyase
3dc2a29114 Merge branch 'main' into feat/playtime-in-reviews 2025-11-02 19:25:31 +02:00
Chubby Granny Chaser
6ebf7766aa Merge branch 'main' into feat/reviews-in-profile 2025-11-02 17:04:58 +00:00
Moyasee
b578af4612 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-02 18:48:13 +02:00
Moyasee
6f6b7d49ac fix: removed void and converted conditional to boolean 2025-11-02 18:47:26 +02:00
Moyase
5c445f8a90 Merge branch 'main' into feat/displaying-new-game-update 2025-11-02 18:41:01 +02:00
Moyasee
87d35da9fc fix: deleted comments 2025-11-02 18:24:10 +02:00
Moyasee
5067cf163e feat: added new badge to repacks-modal, set up badge clearing 2025-11-02 18:22:37 +02:00
Moyasee
efab242c74 ci: showing new badge in repack-modal 2025-10-31 23:17:06 +02:00
Zamitto
19bf99ff11 chore: add sleep to aur script 2025-10-31 16:16:03 -03:00
Zamitto
9c00a17193 Merge branch 'release/v3.7.2' 2025-10-31 13:58:14 -03:00
Zamitto
d167628ed4 fix: prevent crash when detectedLanguage is null
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-31 13:57:15 -03:00
Zamitto
59cfce86ae Merge pull request #1841 from JarEXE/fix/achievement-notification-position
Fix: [Linux] achievement notification positioning on multi-monitor setups
2025-10-31 13:23:41 -03:00
Zamitto
51c4e4f5b3 chore: bump version 2025-10-31 13:07:06 -03:00
jarexe
138120460c fix: correct achievement notification positioning on multi-monitor setups 2025-10-31 10:57:44 -03:00
Zamitto
c71f5947ba feat: use new ep to track game playtime 2025-10-31 10:20:11 -03:00
Chubby Granny Chaser
ff8a61ff7a fix: fixing review partial 2025-10-31 12:05:24 +00:00
Chubby Granny Chaser
d1d46971b6 fix: fixing review partial 2025-10-31 12:03:35 +00:00
Chubby Granny Chaser
b8af69b0fb fix: fixing review partial 2025-10-31 12:01:42 +00:00
Zamitto
1af69465c1 Merge pull request #1839 from hydralauncher/fix/custom-games-requests
fix: requests for custom games
2025-10-31 07:08:51 -03:00
Chubby Granny Chaser
f6c12c22b5 Merge branch 'main' into feat/reviews-in-profile 2025-10-31 07:29:22 +00:00
Chubby Granny Chaser
539010d817 Merge branch 'main' into feat/playtime-in-reviews 2025-10-31 07:29:13 +00:00
Zamitto
aa148c0b70 fix: trim
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-30 20:01:47 -03:00
Moyase
a83a96f214 Merge pull request #1840 from hydralauncher/feat/catalogue-manual-pagination
fix: removed ability to enter non-number symbols to pagination
2025-10-31 00:46:39 +02:00
Moyasee
4dd3c9de76 fix: formatting 2025-10-30 23:26:22 +02:00
Moyasee
101bc35460 feat: sidebar badge on new game download option 2025-10-30 23:21:31 +02:00
Zamitto
2aa31c0db0 feat: limit game text search to 255 chars 2025-10-30 15:34:49 -03:00
Zamitto
4bfe6d7f86 feat: limit game text search to 255 chars 2025-10-30 15:32:08 -03:00
Zamitto
aadf648a2b chore: unnecessary casting 2025-10-30 07:58:43 -03:00
Zamitto
87dbd548d0 Merge branch 'release/v3.7.2' into fix/custom-games-requests 2025-10-30 07:58:31 -03:00
Zamitto
459bf73121 fix: request download-sources on custom game
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-30 07:36:23 -03:00
Zamitto
a2ef0f304d fix: playtime count and custom games request on process watcher 2025-10-30 07:35:49 -03:00
Zamitto
b04561986e Merge pull request #1838 from hydralauncher/main
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
sync release 3.7.2
2025-10-29 23:07:16 -03:00
Zamitto
ef52d710ed Merge branch 'main' into feat/reviews-in-profile 2025-10-29 15:28:39 -03:00
Zamitto
31d57a784e Merge branch 'main' into feat/playtime-in-reviews 2025-10-29 15:28:37 -03:00
Chubby Granny Chaser
2fce12eba7 Merge branch 'main' into feat/reviews-in-profile 2025-10-29 16:55:36 +00:00
Chubby Granny Chaser
1427775c98 Merge branch 'main' into feat/playtime-in-reviews 2025-10-29 16:55:26 +00:00
Moyasee
5c770bc7e7 fix: unnecessary assertion 2025-10-27 20:12:24 +02:00
Moyasee
b431ed479c fix: converted conditional to boolean 2025-10-27 20:07:08 +02:00
Moyasee
9e09a5decb fix: translation key fix and formatting 2025-10-27 19:28:29 +02:00
Moyasee
1e1a1c61c9 feat: showing playtime in review and changed positions of elements in review 2025-10-27 19:22:59 +02:00
Moyasee
8de6c92d28 ci: formatting 2025-10-24 08:19:55 +03:00
Moyasee
29e1713824 fix: upvote/downvote button arent being disabled after click 2025-10-23 20:06:37 +03:00
Moyasee
81a77411cc ci: fix gap between game image and game name in reviews 2025-10-23 16:54:18 +03:00
Moyasee
cc95deb709 fix: proreply reseting user reviews on profile changing 2025-10-23 14:40:02 +03:00
Moyasee
daf9751cf6 ci: import formatting 2025-10-23 14:27:03 +03:00
Moyasee
d21ec52814 ci: deleted comments 2025-10-23 12:06:23 +03:00
Moyasee
f539977431 fix: refactoring functions to prevent nesting more than 4 lvls 2025-10-23 11:53:35 +03:00
Moyasee
3ff20417d5 fix: extracted ternary operation 2025-10-23 11:37:50 +03:00
Moyasee
65f83399f5 ci: merge 2025-10-23 11:29:27 +03:00
Moyasee
eb34f051e1 Merge branch 'feat/reviews-in-profile' of https://github.com/hydralauncher/hydra into feat/reviews-in-profile 2025-10-23 11:28:40 +03:00
Moyasee
ab27f3295e fix: duplicate selectors and if statements should not be the only statement 2025-10-23 11:26:29 +03:00
Chubby Granny Chaser
3782f79100 Merge branch 'main' into feat/reviews-in-profile 2025-10-23 09:15:57 +01:00
Moyasee
86ab5b107b ci: formatting 2025-10-23 10:34:15 +03:00
Moyasee
acf8f340dd ci: review message ui change and fix loading reviews positioning 2025-10-23 10:33:29 +03:00
ctrlcat0x
f5470b29c0 style: adjust hover effects and dimensions for game cards; refine context menu actions 2025-10-23 10:58:31 +05:30
Moyasee
035f6e8d24 ci: formatting 2025-10-22 22:05:05 +03:00
Moyasee
362d6b634e feat: added reviews in profile and tabs 2025-10-22 21:13:05 +03:00
ctrlcat0x
a0a967aacd style: update compact view styles for game cards; adjust grid layout and add button order 2025-10-22 18:28:24 +05:30
ctrlcat0x
e19102ea66 style: update active state styles for filter and view options; adjust achievement progress bar styles 2025-10-22 16:12:12 +05:30
ctrlcat0x
107b61f663 style: update active state colors for filter and view options 2025-10-22 14:46:25 +05:30
ctrlcat0x
811a6ad955 refactor: remove unused imports and download logic from LibraryGameCard 2025-10-22 14:42:47 +05:30
ctrlcat0x
6fb8bbf744 he commit 2025-10-22 14:29:55 +05:30
ctrlcat0x
459017a4a6 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/game-library 2025-10-22 14:28:00 +05:30
Sahil Rana
d6ff8f670e Merge branch 'main' into feat/game-library 2025-10-22 14:26:09 +05:30
ctrlcat0x
33e0d50966 feat: add achievements tracking to game library
- Updated `get-library.ts` to include unlocked and total achievement counts for each game.
- Removed `library-game-card-detailed.tsx` and its associated styles as part of the refactor.
- Enhanced `library-game-card-large.tsx` to display achievements with progress bars.
- Modified `library-game-card.scss` and `library-game-card-large.scss` to style the achievements section.
- Introduced a new `search-bar` component for filtering the game library.
- Implemented fuzzy search functionality in the library view.
- Updated `view-options` to improve UI consistency.
- Added achievement-related properties to the `LibraryGame` type in `index.ts`.
- Created a new `copilot-instructions.md` for project guidelines.
2025-10-22 14:24:04 +05:30
Sahil Rana
361073d3f8 Merge branch 'main' into feat/game-library 2025-10-20 23:51:13 +05:30
ctrlcat0x
d168e20385 feat(library): implement large game card and enhance library UI
- Added `LibraryGameCardLarge` component for displaying games in a larger format with improved styling and animations.
- Introduced SCSS styles for the large game card, including hover effects and gradient overlays.
- Updated `LibraryGameCard` component to support mouse enter and leave events for better interaction.
- Enhanced the library view options with new styles and functionality for switching between grid, compact, and large views.
- Improved overall layout and responsiveness of the library page, ensuring a better user experience across different screen sizes.
- Added tooltips for playtime information and context menus for game actions.
2025-10-20 23:43:47 +05:30
156 changed files with 8514 additions and 1144 deletions

View File

@@ -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.

View File

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

View File

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

View File

@@ -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:**
-

View File

@@ -2,6 +2,9 @@ name: Build
on:
pull_request:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -95,6 +95,8 @@ jobs:
- name: Update PKGBUILD and .SRCINFO
if: steps.check-update.outputs.update_needed == 'true'
run: |
# sleeps for 1 minute to be sure GH updated the release info
sleep 60
# Update pkgver in PKGBUILD
cd hydra-launcher-bin
NEW_VERSION="${{ steps.get-version.outputs.version }}"
@@ -135,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"

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.2",
"version": "3.7.5",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
@@ -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",
@@ -75,6 +76,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0",
"react-infinite-scroll-component": "^6.1.0",
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
@@ -83,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",

View File

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

View File

@@ -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)

View File

@@ -13,8 +13,10 @@
},
"sidebar": {
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Available",
"my_library": "My library",
"downloading_metadata": "{{title}} (Downloading metadata…)",
"paused": "{{title}} (Paused)",
@@ -92,8 +94,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 +204,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",
@@ -223,6 +234,7 @@
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
"review_played_for": "Played for",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
@@ -361,7 +373,10 @@
"show_original": "Show original",
"show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})",
"hide_original": "Hide original"
"hide_original": "Hide original",
"review_from_blocked_user": "Review from blocked user",
"show": "Show",
"hide": "Hide"
},
"activation": {
"title": "Activate Hydra",
@@ -400,7 +415,11 @@
"resume_seeding": "Resume seeding",
"options": "Manage",
"extract": "Extract files",
"extracting": "Extracting files…"
"extracting": "Extracting files…",
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Downloads path",
@@ -551,6 +570,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",
@@ -689,7 +717,31 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews"
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"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",

View File

@@ -13,8 +13,10 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Disponible",
"my_library": "Mi Librería",
"downloading_metadata": "{{title}} (Descargando metadatos…)",
"paused": "{{title}} (Pausado)",
@@ -92,8 +94,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 +202,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",
@@ -325,6 +336,7 @@
"maybe_later": "Tal vez después",
"no_repacks_found": "Sin fuentes encontradas para este juego",
"no_reviews_yet": "Sin reseñas aún",
"review_played_for": "Jugado por",
"properties": "Propiedades",
"rating": "Calificación",
"rating_count": "Calificación",
@@ -361,7 +373,10 @@
"you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego",
"language": "Idioma",
"caption": "Subtítulo",
"audio": "Audio"
"audio": "Audio",
"review_from_blocked_user": "Reseña de usuario bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Activar Hydra",
@@ -400,7 +415,11 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Ruta de descarga",
@@ -538,6 +557,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.",
@@ -678,7 +703,11 @@
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados"
"game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...",
"no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña"
},
"achievement": {
"achievement_unlocked": "Logro desbloqueado",
@@ -708,5 +737,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"
}
}

View File

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

View File

@@ -13,8 +13,10 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)",
@@ -92,11 +94,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 +192,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",
@@ -317,6 +328,7 @@
"sort_lowest_score": "Menor Nota",
"sort_most_voted": "Mais Votadas",
"no_reviews_yet": "Ainda não há avaliações",
"review_played_for": "Jogado por",
"be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
"rating": "Avaliação",
"rating_stats": "Avaliação",
@@ -349,7 +361,10 @@
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"rating_count": "Avaliação"
"rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Ativação",
@@ -388,7 +403,11 @@
"resume_seeding": "Semear",
"options": "Gerenciar",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…"
"extracting": "Extraindo arquivos…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Diretório dos downloads",
@@ -537,6 +556,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",
@@ -693,7 +718,11 @@
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",
@@ -723,5 +752,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"
}
}

View File

@@ -15,6 +15,7 @@
"catalogue": "Catálogo",
"downloads": "Transferências",
"settings": "Definições",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (A transferir metadados…)",
"paused": "{{title}} (Em pausa)",
@@ -30,11 +31,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."
},
@@ -180,7 +189,11 @@
"download_error_not_cached_on_torbox": "Este download não está disponível no TorBox e a verificação do status do download não está disponível.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar"
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"review_from_blocked_user": "Avaliação de utilizador bloqueado",
"show": "Mostrar",
"hide": "Ocultar",
"review_played_for": "Jogado por"
},
"activation": {
"title": "Ativação",
@@ -217,7 +230,13 @@
"seeding": "A semear",
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Opções"
"options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Local das transferências",
@@ -466,7 +485,11 @@
"achievements_unlocked": "Conquistas desbloqueadas",
"earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Mostre as suas conquistas no perfil",
"show_points_on_profile": "Mostre os seus pontos ganhos no perfil"
"show_points_on_profile": "Mostre os seus pontos ganhos no perfil",
"user_reviews": "Avaliações",
"loading_reviews": "A carregar avaliações...",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Eliminar avaliação"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",

View File

@@ -13,8 +13,10 @@
},
"sidebar": {
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Доступно",
"my_library": "Библиотека",
"downloading_metadata": "{{title}} (Загрузка метаданных…)",
"paused": "{{title}} (Приостановлено)",
@@ -92,8 +94,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 +204,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, чтобы увидеть изменения",
@@ -227,6 +238,7 @@
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
@@ -351,6 +363,8 @@
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены",
"show": "Показать",
"hide": "Скрыть",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
@@ -361,7 +375,8 @@
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал"
"hide_original": "Скрыть оригинал",
"review_from_blocked_user": "Отзыв от заблокированного пользователя"
},
"activation": {
"title": "Активировать Hydra",
@@ -400,7 +415,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",
@@ -551,6 +570,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": "Автоматически начинать воспроизведение трейлеров на странице игры",
@@ -689,7 +714,11 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов"
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",
@@ -719,5 +748,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": "Избранное"
}
}

View File

@@ -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ı",

View File

@@ -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": "成就已解锁",

View File

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

View File

@@ -0,0 +1,3 @@
import "./get-session-hash";
import "./open-auth-window";
import "./sign-out";

View File

@@ -0,0 +1,2 @@
import "./check-for-updates";
import "./restart-and-install-update";

View File

@@ -0,0 +1,4 @@
import "./get-game-assets";
import "./get-game-shop-details";
import "./get-game-stats";
import "./get-random-game";

View File

@@ -0,0 +1,4 @@
import "./download-game-artifact";
import "./get-game-backup-preview";
import "./select-game-backup-path";
import "./upload-save-game";

View File

@@ -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
);

View File

@@ -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
);

View 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";

View File

@@ -0,0 +1,2 @@
import "./check-folder-write-permission";
import "./get-disk-free-space";

View File

@@ -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");

View 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;
};

View File

@@ -0,0 +1,6 @@
import "./leveldb-get";
import "./leveldb-put";
import "./leveldb-del";
import "./leveldb-clear";
import "./leveldb-values";
import "./leveldb-iterator";

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View File

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

View 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";

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { mergeWithRemoteGames } from "@main/services";
const refreshLibraryAssets = async () => {
await mergeWithRemoteGames();
};
registerEvent("refreshLibraryAssets", refreshLibraryAssets);

View 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";

View File

@@ -0,0 +1,3 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";

View File

@@ -0,0 +1,4 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

@@ -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);

View 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);

View 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);

View 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);

View 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);

View 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";

View 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);

View 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";

View File

@@ -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()) {

View File

@@ -0,0 +1,5 @@
import "./authenticate-real-debrid";
import "./authenticate-torbox";
import "./auto-launch";
import "./get-user-preferences";
import "./update-user-preferences";

View File

@@ -0,0 +1,3 @@
import "./get-auth";
import "./get-compared-unlocked-achievements";
import "./get-unlocked-achievements";

View File

@@ -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";

View 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",
});
};

View File

@@ -7,3 +7,4 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";

View File

@@ -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)
};

View File

@@ -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,8 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
DownloadSourcesChecker,
WSClient,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -56,14 +58,17 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// WSClient.connect();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
});
const downloads = await downloadsSublevel
.values()
.all()
.then((games) => {
return sortBy(games, "timestamp", "DESC");
return orderBy(games, "timestamp", "desc");
});
downloads.forEach((download) => {

View File

@@ -167,6 +167,8 @@ export class AchievementWatcherManager {
shop: GameShop,
objectId: string
) {
if (shop === "custom") return;
const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return;

View 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);
}
}
}

View File

@@ -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"
);
});

View File

@@ -1,6 +1,7 @@
import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import { logger } from "@main/services";
export class DatanodesApi {
private static readonly jar = new CookieJar();
@@ -20,51 +21,42 @@ export class DatanodesApi {
await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
method_free: "Free Download >>",
dl: "1",
});
const formData = new FormData();
formData.append("op", "download2");
formData.append("id", fileCode);
formData.append("rand", "");
formData.append("referer", "https://datanodes.to/download");
formData.append("method_free", "Free Download >>");
formData.append("method_premium", "");
formData.append("__dl", "1");
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
formData,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
priority: "u=1, i",
"sec-ch-ua":
'"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
Referer: "https://datanodes.to/download",
Origin: "https://datanodes.to",
"Content-Type": "application/x-www-form-urlencoded",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
}
);
if (response.status === 302) {
return response.headers["location"];
}
if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(response.data.url);
}
const htmlContent = String(response.data);
if (!htmlContent) {
throw new Error("Empty response received");
}
const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/;
const downloadLinkMatch = downloadLinkRegex.exec(htmlContent);
if (downloadLinkMatch) {
return downloadLinkMatch[1];
}
throw new Error("Failed to get the download link");
} catch (error) {
console.error("Error fetching download URL:", error);
logger.error("Error fetching download URL:", error);
throw error;
}
}

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { WSClient } from "./ws";
export interface HydraApiOptions {
needsAuth?: boolean;
@@ -29,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;
@@ -103,8 +104,8 @@ export class HydraApi {
await clearGamesRemoteIds();
uploadGamesBatch();
// WSClient.close();
// WSClient.connect();
WSClient.close();
WSClient.connect();
const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();
@@ -399,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;
}
}
}

View File

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

View File

@@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api";
import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => {
if (game.shop === "custom") {
return;
}
return HydraApi.post(`/profile/games`, {
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),

View File

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

View File

@@ -1,12 +1,16 @@
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async (
export const trackGamePlaytime = async (
game: Game,
deltaInMillis: number,
lastTimePlayed: Date
) => {
return HydraApi.put(`/profile/games/${game.remoteId}`, {
if (game.shop === "custom") {
return;
}
return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});

View File

@@ -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);
}
};

View File

@@ -1,5 +1,5 @@
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import { createGame, trackGamePlaytime } from "./library-sync";
import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc";
import axios from "axios";
@@ -198,11 +198,6 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) {
const now = performance.now();
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now,
firstTick: now,
@@ -220,8 +215,15 @@ function onOpenGame(game: Game) {
})
.catch(() => {});
if (game.shop === "custom") return;
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
if (game.remoteId) {
updateGamePlaytime(
trackGamePlaytime(
game,
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
new Date()
@@ -255,43 +257,46 @@ function onTickGame(game: Game) {
const delta = now - gamePlaytime.lastTick;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
const updatedGame: Game = {
...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(),
});
};
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
});
if (currentTick % TICKS_TO_UPDATE_API === 0) {
if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") {
const deltaToSync =
now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
const gamePromise = game.remoteId
? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
: createGame(game);
gamePromise
.then(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
})
.finally(() => {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
lastSyncTick: now,
});
});
@@ -299,11 +304,24 @@ function onTickGame(game: Game) {
}
const onCloseGame = (game: Game) => {
const now = performance.now();
const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId)
)!;
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
const delta = now - gamePlaytime.lastTick;
const updatedGame: Game = {
...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(),
};
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
if (game.shop === "custom") return;
if (game.remoteId) {
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
@@ -315,20 +333,20 @@ const onCloseGame = (game: Game) => {
}
const deltaToSync =
performance.now() -
now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
.then(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
});

View File

@@ -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;
})

View File

@@ -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 {

View File

@@ -289,12 +289,6 @@ export class WindowManager {
}
}
private static loadNotificationWindowURL() {
if (this.notificationWindow) {
this.loadWindowURL(this.notificationWindow, "achievement-notification");
}
}
private static readonly NOTIFICATION_WINDOW_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
@@ -302,52 +296,66 @@ export class WindowManager {
position: AchievementCustomNotificationPosition | undefined
) {
const display = screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize;
const {
x: displayX,
y: displayY,
width: displayWidth,
height: displayHeight,
} = display.bounds;
if (position === "bottom-left") {
return {
x: 0,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
x: displayX,
y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-left") {
return {
x: displayX,
y: displayY,
};
}
if (position === "top-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: 0,
x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: displayY,
};
}
if (position === "top-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: 0,
x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
y: displayY,
};
}
return {
x: 0,
y: 0,
x: displayX,
y: displayY,
};
}
public static async createNotificationWindow() {
if (this.notificationWindow) return;
if (process.platform === "darwin") return;
const userPreferences = await db.get<string, UserPreferences | undefined>(
levelKeys.userPreferences,
{
@@ -387,7 +395,7 @@ export class WindowManager {
this.notificationWindow.setIgnoreMouseEvents(true);
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL();
this.loadWindowURL(this.notificationWindow, "achievement-notification");
if (!app.isPackaged || isStaging) {
this.notificationWindow.webContents.openDevTools();

View File

@@ -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);
}
}
};

View File

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

View File

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

View File

@@ -90,6 +90,7 @@ img {
progress[value] {
-webkit-appearance: none;
appearance: none;
}
.container {

View File

@@ -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();
}, []);
@@ -279,7 +296,11 @@ export function App() {
<article className="container">
<Header />
<section ref={contentRef} className="container__content">
<section
ref={contentRef}
id="scrollableDiv"
className="container__content"
>
<Outlet />
</section>
</article>

View File

@@ -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 && (

View File

@@ -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"

View File

@@ -70,8 +70,10 @@ export function GameContextMenu({
onClick: () => {
if (isGameRunning) {
void handleCloseGame();
} else {
} else if (canPlay) {
void handlePlayGame();
} else {
handleOpenDownloadOptions();
}
},
disabled: isDeleting,

View File

@@ -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 }));
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}
/>
</>
);
}

View File

@@ -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"

View File

@@ -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";

View 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>
)
)}
</>
);
}

View 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;
}
}

View 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);
}

View File

@@ -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",
@@ -26,4 +32,15 @@ export const routes = [
nameKey: "settings",
render: () => <GearIcon />,
},
{
path: "https://hydrawrapped.com",
nameKey: "hydra_2025_wrapped",
render: () => (
<img
src="https://cdn.losbroxas.org/thumbnail_hydra_badge2_fb01af31e3.png"
alt="Hydra 2025 Wrapped"
style={{ width: 16, height: 16 }}
/>
),
},
];

View File

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

View File

@@ -88,6 +88,34 @@
);
}
}
&--wrapped {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.25) 0%,
rgba(123, 104, 238, 0.2) 25%,
rgba(59, 130, 246, 0.25) 50%,
rgba(96, 165, 250, 0.2) 75%,
rgba(74, 144, 226, 0.25) 100%
);
background-size: 200% 200%;
animation: wrapped-gradient-flow 8s ease infinite;
color: globals.$muted-color;
position: relative;
overflow: hidden;
&:hover {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.35) 0%,
rgba(123, 104, 238, 0.3) 25%,
rgba(59, 130, 246, 0.35) 50%,
rgba(96, 165, 250, 0.3) 75%,
rgba(74, 144, 226, 0.35) 100%
);
background-size: 200% 200%;
}
}
}
&__menu-item-button {
@@ -106,6 +134,21 @@
overflow: hidden;
}
&__menu-item-marquee {
overflow: hidden;
flex: 1;
min-width: 0;
}
&__menu-item-marquee-content {
display: inline-flex;
}
&__menu-item-marquee-content span {
display: inline-block;
flex-shrink: 0;
}
&__game-icon {
width: 20px;
height: 20px;
@@ -115,6 +158,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;
@@ -215,3 +271,24 @@
}
}
}
@keyframes wrapped-gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 1em));
}
}

View File

@@ -22,6 +22,8 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { logger } from "@renderer/logger";
import { motion } from "framer-motion";
import {
CommentDiscussionIcon,
PlayIcon,
@@ -238,8 +240,32 @@ export function Sidebar() {
return game.title;
};
const handleSidebarItemClick = (path: string) => {
if (path !== location.pathname) {
const handleSidebarItemClick = async (path: string) => {
if (path.startsWith("http")) {
if (path === "https://hydrawrapped.com") {
try {
const auth = await window.electron.getAuth();
if (auth) {
const payload = {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
expiresIn: 3600,
};
const base64Payload = btoa(JSON.stringify(payload));
window.electron.openExternal(
`${path}?payload=${encodeURIComponent(base64Payload)}`
);
} else {
window.electron.openExternal(path);
}
} catch (error) {
logger.error("Failed to get auth for wrapped:", error);
window.electron.openExternal(path);
}
} else {
window.electron.openExternal(path);
}
} else if (path !== location.pathname) {
navigate(path);
}
};
@@ -297,7 +323,10 @@ export function Sidebar() {
<li
key={nameKey}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active": location.pathname === path,
"sidebar__menu-item--active":
!path.startsWith("http") && location.pathname === path,
"sidebar__menu-item--wrapped":
nameKey === "hydra_2025_wrapped",
})}
>
<button
@@ -306,7 +335,33 @@ export function Sidebar() {
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
{nameKey === "hydra_2025_wrapped" ? (
<div className="sidebar__menu-item-marquee">
<motion.div
className="sidebar__menu-item-marquee-content"
animate={{
x: ["0%", "-50%"],
}}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 8,
ease: "linear",
},
}}
>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
</motion.div>
</div>
) : (
<span>{t(nameKey)}</span>
)}
</button>
</li>
))}

View File

@@ -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,
@@ -293,9 +296,14 @@ export function GameDetailsContextProvider({
}, [objectId, shop, userDetails]);
useEffect(() => {
if (shop === "custom") return;
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,

View File

@@ -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 (

View File

@@ -14,12 +14,15 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>;
getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise<void>;
loadMoreLibraryGames: (sortBy?: string) => Promise<boolean>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
libraryGames: UserGame[];
pinnedGames: UserGame[];
hasMoreLibraryGames: boolean;
isLoadingLibraryGames: boolean;
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -30,12 +33,15 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false,
userStats: null,
getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {},
getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {},
loadMoreLibraryGames: async (_sortBy?: string) => false,
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
libraryGames: [],
pinnedGames: [],
hasMoreLibraryGames: false,
isLoadingLibraryGames: false,
});
const { Provider } = userProfileContext;
@@ -62,6 +68,9 @@ export function UserProfileContextProvider({
DEFAULT_USER_PROFILE_BACKGROUND
);
const [selectedBackgroundImage, setSelectedBackgroundImage] = useState("");
const [libraryPage, setLibraryPage] = useState(0);
const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true);
const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
@@ -93,7 +102,13 @@ export function UserProfileContextProvider({
}, [userId]);
const getUserLibraryGames = useCallback(
async (sortBy?: string) => {
async (sortBy?: string, reset = true) => {
if (reset) {
setLibraryPage(0);
setHasMoreLibraryGames(true);
setIsLoadingLibraryGames(true);
}
try {
const params = new URLSearchParams();
params.append("take", "12");
@@ -115,18 +130,74 @@ export function UserProfileContextProvider({
if (response) {
setLibraryGames(response.library);
setPinnedGames(response.pinnedGames);
setHasMoreLibraryGames(response.library.length === 12);
} else {
setLibraryGames([]);
setPinnedGames([]);
setHasMoreLibraryGames(false);
}
} catch (error) {
setLibraryGames([]);
setPinnedGames([]);
setHasMoreLibraryGames(false);
} finally {
setIsLoadingLibraryGames(false);
}
},
[userId]
);
const loadMoreLibraryGames = useCallback(
async (sortBy?: string): Promise<boolean> => {
if (isLoadingLibraryGames || !hasMoreLibraryGames) {
return false;
}
setIsLoadingLibraryGames(true);
try {
const nextPage = libraryPage + 1;
const params = new URLSearchParams();
params.append("take", "12");
params.append("skip", String(nextPage * 12));
if (sortBy) {
params.append("sortBy", sortBy);
}
const queryString = params.toString();
const url = queryString
? `/users/${userId}/library?${queryString}`
: `/users/${userId}/library`;
const response = await window.electron.hydraApi.get<{
library: UserGame[];
pinnedGames: UserGame[];
}>(url);
if (response && response.library.length > 0) {
setLibraryGames((prev) => {
const existingIds = new Set(prev.map((game) => game.objectId));
const newGames = response.library.filter(
(game) => !existingIds.has(game.objectId)
);
return [...prev, ...newGames];
});
setLibraryPage(nextPage);
setHasMoreLibraryGames(response.library.length === 12);
return true;
} else {
setHasMoreLibraryGames(false);
return false;
}
} catch (error) {
setHasMoreLibraryGames(false);
return false;
} finally {
setIsLoadingLibraryGames(false);
}
},
[userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames]
);
const getUserProfile = useCallback(async () => {
getUserStats();
getUserLibraryGames();
@@ -164,6 +235,8 @@ export function UserProfileContextProvider({
setLibraryGames([]);
setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
setLibraryPage(0);
setHasMoreLibraryGames(true);
getUserProfile();
getBadges();
@@ -177,12 +250,15 @@ export function UserProfileContextProvider({
isMe,
getUserProfile,
getUserLibraryGames,
loadMoreLibraryGames,
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
libraryGames,
pinnedGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
}}
>
{children}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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}`;
};

View File

@@ -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";

View File

@@ -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));
});
}, []);

View 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]);
}

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