Compare commits

..

38 Commits

Author SHA1 Message Date
Moyasee
85a4bdb7b1 feat: implement common redistributables preflight checks and UI updates
- Added preflight check for common redistributables during game launch.
- Introduced new translation keys for preflight status messages.
- Updated GameLauncher component to handle preflight progress and display status.
- Enhanced CommonRedistManager with methods to reset and check preflight status.
- Integrated logging for preflight checks and redistributable installations.
- Added spinner animation to indicate loading state in the UI.
2026-01-29 18:23:40 +02:00
Moyase
ae0f1ce355 Merge branch 'main' into feat/LBX-457 2026-01-26 17:36:08 +02:00
Chubby Granny Chaser
53bd31dec0 Merge pull request #1941 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-bb754c2437
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
chore(deps): bump tar from 7.5.2 to 7.5.3 in the npm_and_yarn group across 1 directory
2026-01-26 15:30:07 +00:00
Chubby Granny Chaser
9f00df3fb0 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-bb754c2437 2026-01-26 15:30:00 +00:00
Chubby Granny Chaser
ce66710dac Merge pull request #1960 from hydralauncher/feat/LBX-467
Feat: Changing order of queued downloads and adding games directly to queue
2026-01-26 15:28:27 +00:00
Moyasee
6cbd59cc8f Merge branch 'feat/LBX-457' of https://github.com/hydralauncher/hydra into feat/LBX-457 2026-01-26 10:43:36 +02:00
Moyasee
edd94ba7c4 chore: update dependencies and refactor game launch logic 2026-01-26 10:42:43 +02:00
Moyase
1a69c512bb Merge branch 'main' into feat/LBX-457 2026-01-25 20:34:42 +02:00
Moyasee
884aabbfc0 Merge branch 'feat/LBX-467' of https://github.com/hydralauncher/hydra into feat/LBX-467 2026-01-25 10:41:26 +02:00
Moyasee
d4bd8f7bf1 refactor: remove unnecessary download key deletion in game installer actions and simplify file size handling in download manager 2026-01-25 10:40:38 +02:00
Moyasee
a54dacb2da chore: add isMainWindowOpen declaration 2026-01-25 08:35:20 +02:00
Moyasee
e0bae9072c feat: add isMainWindowOpen functionality and enhance game launcher UI 2026-01-25 08:33:53 +02:00
Chubby Granny Chaser
ad812c393d Merge branch 'main' into feat/LBX-467 2026-01-25 06:14:21 +00:00
Zamitto
57c2e74013 chore: update ww sdk
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-24 20:54:54 -03:00
Chubby Granny Chaser
17528e74bb Merge pull request #1944 from anderlli0053/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Added Slovenian translation
2026-01-24 18:10:02 +00:00
Chubby Granny Chaser
355993b954 Merge branch 'main' into main 2026-01-24 18:09:44 +00:00
Chubby Granny Chaser
ed79b8faeb Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-bb754c2437 2026-01-24 18:09:03 +00:00
Chubby Granny Chaser
4aaaecbee5 Merge pull request #1939 from Wkeynhk/main
Fix RU translation
2026-01-24 18:08:56 +00:00
Chubby Granny Chaser
802b4bd26b Merge branch 'main' into main 2026-01-24 18:03:20 +00:00
Moyasee
c9afd65536 refactor: simplify game entry preparation in download processes by consolidating logic into a helper function 2026-01-24 19:56:55 +02:00
Moyasee
d448a699da refactor: streamline error handling in download processes by utilizing a dedicated error handler 2026-01-24 19:50:25 +02:00
Moyasee
eea7148108 refactor: enhance download management by validating URLs and adding file size handling 2026-01-24 19:38:00 +02:00
Moyasee
fb1380356e feat: add functionality to manage download queue with new actions and translations 2026-01-24 18:46:07 +02:00
Moyasee
82f3dd8268 Merge branch 'feat/LBX-457' of https://github.com/hydralauncher/hydra into feat/LBX-457 2026-01-22 09:43:54 +02:00
Moyasee
bdbf54f72a fix: add delays to game launcher and open game functions 2026-01-22 09:43:08 +02:00
dependabot[bot]
3f82b13b1f 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.2 to 7.5.3
- [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.2...v7.5.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-21 21:28:17 +00:00
Zamitto
baa2c8471a chore: bump ww version
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-21 18:26:17 -03:00
Moyase
5304691131 Merge branch 'main' into feat/LBX-457 2026-01-21 21:06:14 +02:00
Moyase
63bb5ca511 Merge pull request #1948 from hydralauncher/fix/LBX-454
refactor: improve notification handling in SidebarProfile component
2026-01-21 21:05:46 +02:00
Moyasee
9824f7a905 feat: implement game launcher window functionality and enhance deep link handling 2026-01-21 21:04:22 +02:00
Moyase
073d3f25e3 Merge branch 'main' into fix/LBX-454 2026-01-21 18:24:44 +02:00
Moyase
066185e6ee Merge pull request #1947 from hydralauncher/feat/LBX-452
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: implement dynamic port discovery for Python RPC service
2026-01-21 18:24:25 +02:00
Moyasee
335f4d33b9 feat: implement deep link handling and game shortcut creation with icon download 2026-01-21 16:52:34 +02:00
Moyasee
5ddfd88ef7 refactor: remove polling to notifications count api 2026-01-21 11:33:42 +02:00
Moyasee
50bafbb7f6 refactor: improve notification handling in SidebarProfile component 2026-01-20 19:41:24 +02:00
Andrew Poženel
128f864ca7 Reformat the translation.json file 2026-01-18 23:02:46 +01:00
Andrew Poženel
a2b993bb9b Added Slovenian translation 2026-01-18 22:36:30 +01:00
Wkeynhk
cafa536f79 Fix RU translation 2026-01-14 16:55:55 +03:00
47 changed files with 2702 additions and 294 deletions

View File

@@ -74,6 +74,7 @@
"lucide-react": "^0.544.0",
"node-7z": "^3.0.0",
"parse-torrent": "^11.0.18",
"png-to-ico": "^3.0.1",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -88,12 +89,12 @@
"sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1",
"tar": "^7.5.2",
"tar": "^7.5.4",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^13.0.0",
"winreg": "^1.2.5",
"workwonders-sdk": "0.0.10",
"workwonders-sdk": "0.1.1",
"ws": "^8.18.1",
"yaml": "^2.6.1",
"yup": "^1.5.0"

View File

@@ -185,6 +185,7 @@
"repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now",
"add_to_queue": "Add to queue",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
@@ -447,7 +448,9 @@
"yes": "Yes",
"no": "No",
"network": "NETWORK",
"peak": "PEAK"
"peak": "PEAK",
"move_up": "Move up",
"move_down": "Move down"
},
"settings": {
"downloads_path": "Downloads path",
@@ -822,6 +825,20 @@
"learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus"
},
"game_launcher": {
"launching": "Launching...",
"launching_base": "Launching",
"open_hydra": "Open Hydra",
"playtime": "Playtime",
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"preflight_checking": "Checking dependencies",
"preflight_downloading": "Downloading dependencies",
"preflight_installing": "Installing dependencies",
"preflight_installing_detail": "{{detail}}"
},
"notifications_page": {
"title": "Notifications",
"mark_all_as_read": "Mark all as read",

View File

@@ -150,7 +150,7 @@
"filter": "Поиск репаков",
"requirements": "Системные требования",
"minimum": "Минимальные",
"recommended": "Рекомендуемые",
"recommended": "Рекомендованные",
"paused": "Приостановлено",
"release_date": "Выпущено {{date}}",
"publisher": "Издатель {{publisher}}",
@@ -189,7 +189,6 @@
"downloader_not_configured": "Доступен, но не настроен",
"downloader_offline": "Ссылка недоступна",
"downloader_not_available": "Недоступно",
"recommended": "Рекомендуется",
"go_to_settings": "Перейти в настройки",
"select_executable": "Выбрать",
"no_executable_selected": "Файл не выбран",
@@ -243,11 +242,11 @@
"show_more": "Показать больше",
"show_less": "Показать меньше",
"reviews": "Отзывы",
"review_played_for": "Играли",
"leave_a_review": "Оставить отзыв",
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
@@ -372,8 +371,6 @@
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены",
"show": "Показать",
"hide": "Скрыть",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
@@ -385,7 +382,9 @@
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"review_from_blocked_user": "Отзыв от заблокированного пользователя"
"review_from_blocked_user": "Отзыв от заблокированного пользователя",
"show": "Показать",
"hide": "Скрыть"
},
"activation": {
"title": "Активировать Hydra",
@@ -722,7 +721,7 @@
"report_reason_spam": "Спам",
"report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:",
"your_friend_code": "Ваш код друга:",
"copy_friend_code": "Копировать код друга",
"copied": "Скопировано!",
"upload_banner": "Загрузить баннер",
@@ -751,10 +750,30 @@
"karma": "Карма",
"karma_count": "карма",
"user_reviews": "Отзывы",
"delete_review": "Удалить отзыв",
"loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
"wrapped_2025": "Wrapped 2025"
},
"library": {
"library": "Библиотека",
"play": "Играть",
"download": "Скачать",
"downloading": "Скачивание",
"game": "игра",
"games": "игры",
"grid_view": "Вид сетки",
"compact_view": "Компактный вид",
"large_view": "Большой вид",
"no_games_title": "Ваша библиотека пуста",
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",
@@ -785,27 +804,6 @@
"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": "Избранное"
},
"notifications_page": {
"title": "Уведомления",
"mark_all_as_read": "Отметить все как прочитанные",

View File

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

View File

@@ -1,12 +1,69 @@
import { registerEvent } from "../register-event";
import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path";
import fs from "node:fs";
import { app } from "electron";
import axios from "axios";
import pngToIco from "png-to-ico";
import { removeSymbolsFromName } from "@shared";
import { GameShop, ShortcutLocation } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import { SystemPath } from "@main/services/system-path";
import { windowsStartMenuPath } from "@main/constants";
import { ASSETS_PATH, windowsStartMenuPath } from "@main/constants";
import { getGameAssets } from "../catalogue/get-game-assets";
import { logger } from "@main/services";
const downloadIcon = async (
shop: GameShop,
objectId: string,
iconUrl?: string | null
): Promise<string | null> => {
if (!iconUrl) {
return null;
}
const iconDir = path.join(ASSETS_PATH, `${shop}-${objectId}`);
const iconPath = path.join(iconDir, "icon.ico");
try {
if (fs.existsSync(iconPath)) {
return iconPath;
}
fs.mkdirSync(iconDir, { recursive: true });
const response = await axios.get(iconUrl, { responseType: "arraybuffer" });
const imageBuffer = Buffer.from(response.data);
const icoBuffer = await pngToIco(imageBuffer);
fs.writeFileSync(iconPath, icoBuffer);
return iconPath;
} catch (error) {
logger.error("Failed to download/convert game icon", error);
return null;
}
};
const createUrlShortcut = (
shortcutPath: string,
url: string,
iconPath?: string | null
): boolean => {
try {
let content = `[InternetShortcut]\nURL=${url}\n`;
if (iconPath) {
content += `IconFile=${iconPath}\nIconIndex=0\n`;
}
fs.writeFileSync(shortcutPath, content);
return true;
} catch (error) {
logger.error("Failed to create URL shortcut", error);
return false;
}
};
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
@@ -17,30 +74,42 @@ const createGameShortcut = async (
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (game) {
const filePath = game.executablePath;
const windowVbsPath = app.isPackaged
? path.join(process.resourcesPath, "windows.vbs")
: undefined;
const options = {
filePath,
name: removeSymbolsFromName(game.title),
outputPath:
location === "desktop"
? SystemPath.getPath("desktop")
: windowsStartMenuPath,
};
return createDesktopShortcut({
windows: { ...options, VBScriptPath: windowVbsPath },
linux: options,
osx: options,
});
if (!game) {
return false;
}
return false;
const shortcutName = removeSymbolsFromName(game.title);
const deepLink = `hydralauncher://run?shop=${shop}&objectId=${objectId}`;
const outputPath =
location === "desktop"
? SystemPath.getPath("desktop")
: windowsStartMenuPath;
const assets = shop === "custom" ? null : await getGameAssets(objectId, shop);
const iconPath = await downloadIcon(shop, objectId, assets?.iconUrl);
if (process.platform === "win32") {
const shortcutPath = path.join(outputPath, `${shortcutName}.url`);
return createUrlShortcut(shortcutPath, deepLink, iconPath);
}
const windowVbsPath = app.isPackaged
? path.join(process.resourcesPath, "windows.vbs")
: undefined;
const options = {
filePath: process.execPath,
arguments: deepLink,
name: shortcutName,
outputPath,
icon: iconPath ?? undefined,
};
return createDesktopShortcut({
windows: { ...options, VBScriptPath: windowVbsPath },
linux: options,
osx: options,
});
};
registerEvent("createGameShortcut", createGameShortcut);

View File

@@ -15,14 +15,7 @@ const deleteGameFolder = async (
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download?.folderName) return;
const folderPath = path.join(
download.downloadPath ?? (await getDownloadsPath()),
download.folderName
);
const metaPath = `${folderPath}.meta`;
if (!download) return;
const deleteFile = async (filePath: string, isDirectory = false) => {
if (fs.existsSync(filePath)) {
@@ -47,8 +40,18 @@ const deleteGameFolder = async (
}
};
await deleteFile(folderPath, true);
await deleteFile(metaPath);
if (download.folderName) {
const folderPath = path.join(
download.downloadPath ?? (await getDownloadsPath()),
download.folderName
);
const metaPath = `${folderPath}.meta`;
await deleteFile(folderPath, true);
await deleteFile(metaPath);
}
await downloadsSublevel.del(downloadKey);
};

View File

@@ -22,7 +22,6 @@ const getGameInstallerActionType = async (
);
if (!fs.existsSync(gamePath)) {
await downloadsSublevel.del(downloadKey);
return "open-folder";
}

View File

@@ -38,7 +38,6 @@ const openGameInstaller = async (
);
if (!fs.existsSync(gamePath)) {
await downloadsSublevel.del(downloadKey);
return true;
}

View File

@@ -1,10 +1,6 @@
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { spawn } from "child_process";
import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
import { parseLaunchOptions } from "../helpers/parse-launch-options";
import { launchGame } from "@main/helpers";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
@@ -13,27 +9,7 @@ const openGame = async (
executablePath: string,
launchOptions?: string | null
) => {
const parsedPath = parseExecutablePath(executablePath);
const parsedParams = parseLaunchOptions(launchOptions);
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
launchOptions,
});
if (parsedParams.length === 0) {
shell.openPath(parsedPath);
return;
}
spawn(parsedPath, parsedParams, { shell: false, detached: true });
await launchGame({ shop, objectId, executablePath, launchOptions });
};
registerEvent("openGame", openGame);

View File

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

View File

@@ -1,12 +1,17 @@
import "./can-install-common-redist";
import "./check-homebrew-folder-exists";
import "./close-game-launcher-window";
import "./delete-temp-file";
import "./show-game-launcher-window";
import "./get-hydra-decky-plugin-info";
import "./hydra-api-call";
import "./install-common-redist";
import "./install-hydra-decky-plugin";
import "./is-main-window-open";
import "./open-checkout";
import "./open-external";
import "./open-main-window";
import "./reset-common-redist-preflight";
import "./save-temp-file";
import "./show-item-in-folder";
import "./show-open-dialog";

View File

@@ -3,6 +3,8 @@ import { CommonRedistManager } from "@main/services/common-redist-manager";
const installCommonRedist = async (_event: Electron.IpcMainInvokeEvent) => {
if (await CommonRedistManager.canInstallCommonRedist()) {
// Reset preflight status so the user can force a re-run
await CommonRedistManager.resetPreflightStatus();
CommonRedistManager.installCommonRedist();
}
};

View File

@@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const isMainWindowOpen = async () => {
return (
WindowManager.mainWindow !== null &&
!WindowManager.mainWindow.isDestroyed() &&
WindowManager.mainWindow.isVisible()
);
};
registerEvent("isMainWindowOpen", isMainWindowOpen);

View File

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

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { CommonRedistManager } from "@main/services/common-redist-manager";
const resetCommonRedistPreflight = async (
_event: Electron.IpcMainInvokeEvent
) => CommonRedistManager.resetPreflightStatus();
registerEvent("resetCommonRedistPreflight", resetCommonRedistPreflight);

View File

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

View File

@@ -0,0 +1,78 @@
import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi, logger } from "@main/services";
import { createGame } from "@main/services/library-sync";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { parseBytes } from "@shared";
import { handleDownloadError, prepareGameEntry } from "@main/helpers";
const addGameToQueue = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const {
objectId,
title,
shop,
downloadPath,
downloader,
uri,
automaticallyExtract,
fileSize,
} = payload;
const gameKey = levelKeys.game(shop, objectId);
const download: Download = {
shop,
objectId,
status: "paused",
progress: 0,
bytesDownloaded: 0,
downloadPath,
downloader,
uri,
folderName: null,
fileSize: parseBytes(fileSize ?? null),
shouldSeed: false,
timestamp: Date.now(),
queued: true,
extracting: false,
automaticallyExtract,
extractionProgress: 0,
};
try {
await DownloadManager.validateDownloadUrl(download);
} catch (err: unknown) {
logger.error("Failed to validate download URL for queue", err);
return handleDownloadError(err, downloader);
}
await prepareGameEntry({ gameKey, title, objectId, shop });
try {
await downloadsSublevel.put(gameKey, download);
const updatedGame = await gamesSublevel.get(gameKey);
await Promise.all([
createGame(updatedGame!).catch(() => {}),
HydraApi.post(`/games/${shop}/${objectId}/download`, null, {
needsAuth: false,
}).catch(() => {}),
]);
return { ok: true };
} catch (err: unknown) {
logger.error("Failed to add game to queue", err);
if (err instanceof Error) {
return { ok: false, error: err.message };
}
return { ok: false };
}
};
registerEvent("addGameToQueue", addGameToQueue);

View File

@@ -1,3 +1,4 @@
import "./add-game-to-queue";
import "./cancel-game-download";
import "./check-debrid-availability";
import "./pause-game-download";
@@ -5,3 +6,4 @@ import "./pause-game-seed";
import "./resume-game-download";
import "./resume-game-seed";
import "./start-game-download";
import "./update-download-queue-position";

View File

@@ -2,14 +2,8 @@ import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi, logger } from "@main/services";
import { createGame } from "@main/services/library-sync";
import { Downloader, DownloadError } from "@shared";
import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
import { AxiosError } from "axios";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { handleDownloadError, prepareGameEntry } from "@main/helpers";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@@ -38,30 +32,7 @@ const startGameDownload = async (
}
}
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
await downloadsSublevel.del(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,
});
} else {
await gamesSublevel.put(gameKey, {
title,
iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId,
shop,
remoteId: null,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
isDeleted: false,
});
}
await prepareGameEntry({ gameKey, title, objectId, shop });
await DownloadManager.cancelDownload(gameKey);
@@ -101,68 +72,7 @@ const startGameDownload = async (
return { ok: true };
} catch (err: unknown) {
logger.error("Failed to start download", err);
if (err instanceof AxiosError) {
if (err.response?.status === 429 && downloader === Downloader.Gofile) {
return { ok: false, error: DownloadError.GofileQuotaExceeded };
}
if (
err.response?.status === 403 &&
downloader === Downloader.RealDebrid
) {
return {
ok: false,
error: DownloadError.RealDebridAccountNotAuthorized,
};
}
if (downloader === Downloader.TorBox) {
return { ok: false, error: err.response?.data?.detail };
}
}
if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "Buzzheavier: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "Buzzheavier: File not found",
};
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "FuckingFast: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "FuckingFast: File not found",
};
}
}
return { ok: false, error: err.message };
}
return { ok: false };
return handleDownloadError(err, downloader);
}
};

View File

@@ -0,0 +1,67 @@
import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
import { orderBy } from "lodash-es";
const updateDownloadQueuePosition = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
direction: "up" | "down"
) => {
const gameKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(gameKey);
if (!download || !download.queued || download.status !== "paused") {
return false;
}
const allDownloads = await downloadsSublevel.values().all();
const queuedDownloads = orderBy(
allDownloads.filter((d) => d.status === "paused" && d.queued),
"timestamp",
"desc"
);
const currentIndex = queuedDownloads.findIndex(
(d) => d.shop === shop && d.objectId === objectId
);
if (currentIndex === -1) {
return false;
}
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= queuedDownloads.length) {
return false;
}
const currentDownload = queuedDownloads[currentIndex];
const adjacentDownload = queuedDownloads[targetIndex];
const currentKey = levelKeys.game(
currentDownload.shop,
currentDownload.objectId
);
const adjacentKey = levelKeys.game(
adjacentDownload.shop,
adjacentDownload.objectId
);
const tempTimestamp = currentDownload.timestamp;
await downloadsSublevel.put(currentKey, {
...currentDownload,
timestamp: adjacentDownload.timestamp,
});
await downloadsSublevel.put(adjacentKey, {
...adjacentDownload,
timestamp: tempTimestamp,
});
return true;
};
registerEvent("updateDownloadQueuePosition", updateDownloadQueuePosition);

View File

@@ -0,0 +1,51 @@
import { AxiosError } from "axios";
import { Downloader, DownloadError } from "@shared";
export const handleDownloadError = (
err: unknown,
downloader: Downloader
): { ok: false; error?: string } => {
if (err instanceof AxiosError) {
if (err.response?.status === 429 && downloader === Downloader.Gofile) {
return { ok: false, error: DownloadError.GofileQuotaExceeded };
}
if (err.response?.status === 403 && downloader === Downloader.RealDebrid) {
return { ok: false, error: DownloadError.RealDebridAccountNotAuthorized };
}
if (downloader === Downloader.TorBox) {
return { ok: false, error: err.response?.data?.detail };
}
}
if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return { ok: false, error: "Buzzheavier: Rate limit exceeded" };
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return { ok: false, error: "Buzzheavier: File not found" };
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return { ok: false, error: "FuckingFast: Rate limit exceeded" };
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return { ok: false, error: "FuckingFast: File not found" };
}
}
return { ok: false, error: err.message };
}
return { ok: false };
};

View File

@@ -0,0 +1,45 @@
import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
import type { GameShop } from "@types";
interface PrepareGameEntryParams {
gameKey: string;
title: string;
objectId: string;
shop: GameShop;
}
export const prepareGameEntry = async ({
gameKey,
title,
objectId,
shop,
}: PrepareGameEntryParams): Promise<void> => {
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
await downloadsSublevel.del(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,
});
} else {
await gamesSublevel.put(gameKey, {
title,
iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId,
shop,
remoteId: null,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
isDeleted: false,
});
}
};

View File

@@ -94,3 +94,6 @@ export const getThemeSoundPath = (
};
export * from "./reg-parser";
export * from "./launch-game";
export * from "./download-error-handler";
export * from "./download-game-helper";

View File

@@ -0,0 +1,66 @@
import { shell } from "electron";
import { spawn } from "node:child_process";
import { GameShop } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import { WindowManager, logger } from "@main/services";
import { CommonRedistManager } from "@main/services/common-redist-manager";
import { parseExecutablePath } from "../events/helpers/parse-executable-path";
import { parseLaunchOptions } from "../events/helpers/parse-launch-options";
export interface LaunchGameOptions {
shop: GameShop;
objectId: string;
executablePath: string;
launchOptions?: string | null;
}
/**
* Shows the launcher window and launches the game executable
* Shared between deep link handler and openGame event
*/
export const launchGame = async (options: LaunchGameOptions): Promise<void> => {
const { shop, objectId, executablePath, launchOptions } = options;
const parsedPath = parseExecutablePath(executablePath);
const parsedParams = parseLaunchOptions(launchOptions);
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
launchOptions,
});
}
await WindowManager.createGameLauncherWindow(shop, objectId);
// Run preflight check for common redistributables (Windows only)
// Wrapped in try/catch to ensure game launch is never blocked
if (process.platform === "win32") {
try {
logger.log("Starting preflight check for game launch", {
shop,
objectId,
});
const preflightPassed = await CommonRedistManager.runPreflight();
logger.log("Preflight check result", { passed: preflightPassed });
} catch (error) {
logger.error(
"Preflight check failed with error, continuing with launch",
error
);
}
}
await new Promise((resolve) => setTimeout(resolve, 2000));
if (parsedParams.length === 0) {
shell.openPath(parsedPath);
return;
}
spawn(parsedPath, parsedParams, { shell: false, detached: true });
};

View File

@@ -13,7 +13,9 @@ import {
} from "@main/services";
import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { db, levelKeys } from "./level";
import { db, gamesSublevel, levelKeys } from "./level";
import { GameShop, UserPreferences } from "@types";
import { launchGame } from "./helpers";
import { loadState } from "./main";
const { autoUpdater } = updater;
@@ -140,24 +142,72 @@ app.whenReady().then(async () => {
if (language) i18n.changeLanguage(language);
if (!process.argv.includes("--hidden")) {
// Check if starting from a "run" deep link - don't show main window in that case
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith("hydralauncher://")
);
const isRunDeepLink = deepLinkArg?.startsWith("hydralauncher://run");
if (!process.argv.includes("--hidden") && !isRunDeepLink) {
WindowManager.createMainWindow();
}
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en");
if (deepLinkArg) {
handleDeepLinkPath(deepLinkArg);
}
});
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
const handleRunGame = async (shop: GameShop, objectId: string) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game?.executablePath) {
logger.error("Game not found or no executable path", { shop, objectId });
return;
}
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
// Only open main window if setting is disabled
if (!userPreferences?.hideToTrayOnGameStart) {
WindowManager.createMainWindow();
}
await launchGame({
shop,
objectId,
executablePath: game.executablePath,
launchOptions: game.launchOptions,
});
};
const handleDeepLinkPath = (uri?: string) => {
if (!uri) return;
try {
const url = new URL(uri);
if (url.host === "run") {
const shop = url.searchParams.get("shop") as GameShop | null;
const objectId = url.searchParams.get("objectId");
if (shop && objectId) {
handleRunGame(shop, objectId);
}
return;
}
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
return;
@@ -190,17 +240,23 @@ const handleDeepLinkPath = (uri?: string) => {
};
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
const deepLink = commandLine.pop();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
// Check if this is a "run" deep link - don't show main window in that case
const isRunDeepLink = deepLink?.startsWith("hydralauncher://run");
if (!isRunDeepLink) {
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
}
}
handleDeepLinkPath(commandLine.pop());
handleDeepLinkPath(deepLink);
});
app.on("open-url", (_event, url) => {

View File

@@ -21,4 +21,5 @@ export const levelKeys = {
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
localNotifications: "localNotifications",
commonRedistPassed: "commonRedistPassed", // Whether common redistributables preflight has passed
};

View File

@@ -6,6 +6,12 @@ import path from "node:path";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import { SystemPath } from "./system-path";
import { db, levelKeys } from "@main/level";
interface RedistCheck {
name: string;
check: () => boolean;
}
export class CommonRedistManager {
private static readonly redistributables = [
@@ -22,6 +28,87 @@ export class CommonRedistManager {
"common_redist_install.log"
);
private static readonly system32Path = process.env.SystemRoot
? path.join(process.env.SystemRoot, "System32")
: path.join("C:", "Windows", "System32");
private static readonly systemChecks: RedistCheck[] = [
{
name: "Visual C++ Runtime",
check: () => {
// Check for VS 2015-2022 runtime DLLs
const vcRuntime140 = path.join(
CommonRedistManager.system32Path,
"vcruntime140.dll"
);
const msvcp140 = path.join(
CommonRedistManager.system32Path,
"msvcp140.dll"
);
return fs.existsSync(vcRuntime140) && fs.existsSync(msvcp140);
},
},
{
name: "DirectX June 2010",
check: () => {
// Check for DirectX June 2010 DLLs
const d3dx9_43 = path.join(
CommonRedistManager.system32Path,
"d3dx9_43.dll"
);
return fs.existsSync(d3dx9_43);
},
},
{
name: "OpenAL",
check: () => {
const openAL = path.join(
CommonRedistManager.system32Path,
"OpenAL32.dll"
);
return fs.existsSync(openAL);
},
},
{
name: ".NET Framework 4.0",
check: () => {
// Check for .NET 4.x runtime
const dotNetPath = path.join(
process.env.SystemRoot || path.join("C:", "Windows"),
"Microsoft.NET",
"Framework",
"v4.0.30319",
"clr.dll"
);
return fs.existsSync(dotNetPath);
},
},
{
name: "XNA Framework 4.0",
check: () => {
// XNA Framework installs to GAC - check for the assembly folder
const windowsDir = process.env.SystemRoot || path.join("C:", "Windows");
const xnaGacPath = path.join(
windowsDir,
"Microsoft.NET",
"assembly",
"GAC_32",
"Microsoft.Xna.Framework"
);
const xnaGacPath64 = path.join(
windowsDir,
"Microsoft.NET",
"assembly",
"GAC_MSIL",
"Microsoft.Xna.Framework"
);
// XNA is rare - most modern games don't need it
// Consider it installed if either GAC path exists
return fs.existsSync(xnaGacPath) || fs.existsSync(xnaGacPath64);
},
},
];
public static async installCommonRedist() {
const abortController = new AbortController();
const timeout = setTimeout(() => {
@@ -75,26 +162,85 @@ export class CommonRedistManager {
);
}
/**
* Checks if all installer files are present in the CommonRedist folder
*/
public static async canInstallCommonRedist() {
return this.redistributables.every((redist) => {
const filePath = path.join(commonRedistPath, redist);
const missingFiles: string[] = [];
return fs.existsSync(filePath);
});
for (const redist of this.redistributables) {
const filePath = path.join(commonRedistPath, redist);
const exists = fs.existsSync(filePath);
if (!exists) {
missingFiles.push(redist);
}
}
if (missingFiles.length > 0) {
logger.log("Missing redistributable installer files:", missingFiles);
logger.log("CommonRedist path:", commonRedistPath);
return false;
}
logger.log("All redistributable installer files present");
return true;
}
/**
* Checks if redistributables are actually installed on the Windows system
* by checking for DLLs in System32 and other locations
*/
public static checkSystemRedistributables(): {
allInstalled: boolean;
missing: string[];
} {
const missing: string[] = [];
for (const redistCheck of this.systemChecks) {
try {
const isInstalled = redistCheck.check();
if (!isInstalled) {
missing.push(redistCheck.name);
}
logger.log(
`System check: ${redistCheck.name} - ${isInstalled ? "installed" : "MISSING"}`
);
} catch (error) {
logger.error(`Error checking ${redistCheck.name}:`, error);
missing.push(redistCheck.name);
}
}
const allInstalled = missing.length === 0;
if (allInstalled) {
logger.log("All system redistributables are installed");
} else {
logger.log("Missing system redistributables:", missing);
}
return { allInstalled, missing };
}
public static async downloadCommonRedist() {
logger.log("Starting download of redistributables to:", commonRedistPath);
if (!fs.existsSync(commonRedistPath)) {
await fs.promises.mkdir(commonRedistPath, { recursive: true });
logger.log("Created CommonRedist directory");
}
for (const redist of this.redistributables) {
const filePath = path.join(commonRedistPath, redist);
if (fs.existsSync(filePath) && redist !== "install.bat") {
logger.log(`Skipping ${redist} - already exists`);
continue;
}
logger.log(`Downloading ${redist}...`);
const response = await axios.get(
`https://github.com/hydralauncher/hydra-common-redist/raw/refs/heads/main/${redist}`,
{
@@ -103,6 +249,171 @@ export class CommonRedistManager {
);
await fs.promises.writeFile(filePath, response.data);
logger.log(`Downloaded ${redist} successfully`);
}
logger.log("All redistributables downloaded");
}
public static async hasPreflightPassed(): Promise<boolean> {
try {
const passed = await db.get<string, boolean>(
levelKeys.commonRedistPassed,
{ valueEncoding: "json" }
);
return passed === true;
} catch {
return false;
}
}
public static async markPreflightPassed(): Promise<void> {
await db.put(levelKeys.commonRedistPassed, true, { valueEncoding: "json" });
logger.log("Common redistributables preflight marked as passed");
}
public static async resetPreflightStatus(): Promise<void> {
try {
await db.del(levelKeys.commonRedistPassed);
logger.log("Common redistributables preflight status reset");
} catch {
// Key might not exist, ignore
}
}
/**
* Run preflight check for game launch
* Returns true if preflight succeeded, false if it failed
* Note: Game launch proceeds regardless of return value
*/
public static async runPreflight(): Promise<boolean> {
logger.log("Running common redistributables preflight check");
// Send initial status to game launcher
this.sendPreflightProgress("checking", null);
// First, ensure installer files are downloaded (quick check)
const canInstall = await this.canInstallCommonRedist();
if (!canInstall) {
logger.log("Installer files not downloaded, downloading now");
this.sendPreflightProgress("downloading", null);
try {
await this.downloadCommonRedist();
logger.log("Installer files downloaded successfully");
} catch (error) {
logger.error("Failed to download installer files", error);
this.sendPreflightProgress("error", "download_failed");
return false;
}
}
// Always check if redistributables are actually installed on the system
const systemCheck = this.checkSystemRedistributables();
if (systemCheck.allInstalled) {
logger.log("All redistributables are installed on the system");
this.sendPreflightProgress("complete", null);
return true;
}
logger.log(
"Some redistributables are missing on the system:",
systemCheck.missing
);
// Install redistributables
logger.log("Installing common redistributables");
this.sendPreflightProgress("installing", null);
try {
const success = await this.installCommonRedistForPreflight();
if (success) {
this.sendPreflightProgress("complete", null);
logger.log("Preflight completed successfully");
return true;
}
logger.error("Preflight installation did not complete successfully");
this.sendPreflightProgress("error", "install_failed");
return false;
} catch (error) {
logger.error("Preflight installation error", error);
this.sendPreflightProgress("error", "install_failed");
return false;
}
}
private static sendPreflightProgress(
status: "checking" | "downloading" | "installing" | "complete" | "error",
detail: string | null
) {
WindowManager.gameLauncherWindow?.webContents.send("preflight-progress", {
status,
detail,
});
}
/**
* Install common redistributables with preflight-specific handling
* Returns a promise that resolves when installation completes
*/
private static async installCommonRedistForPreflight(): Promise<boolean> {
return new Promise((resolve) => {
const abortController = new AbortController();
const timeout = setTimeout(() => {
abortController.abort();
logger.error("Preflight installation timed out");
resolve(false);
}, this.installationTimeout);
const installationCompleteMessage = "Installation complete";
if (!fs.existsSync(this.installationLog)) {
fs.writeFileSync(this.installationLog, "");
}
fs.watch(this.installationLog, { signal: abortController.signal }, () => {
fs.readFile(this.installationLog, "utf-8", (err, data) => {
if (err) {
logger.error("Error reading preflight log file:", err);
return;
}
const tail = data.split("\n").at(-2)?.trim();
if (tail) {
this.sendPreflightProgress("installing", tail);
}
if (tail?.includes(installationCompleteMessage)) {
clearTimeout(timeout);
if (!abortController.signal.aborted) {
abortController.abort();
}
resolve(true);
}
});
});
cp.exec(
path.join(commonRedistPath, "install.bat"),
{
windowsHide: true,
},
(error) => {
if (error) {
logger.error("Failed to run preflight install.bat", error);
clearTimeout(timeout);
if (!abortController.signal.aborted) {
abortController.abort();
}
resolve(false);
}
}
);
});
}
}

View File

@@ -21,7 +21,7 @@ import { RealDebridClient } from "./real-debrid";
import path from "node: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";
@@ -323,7 +323,8 @@ export class DownloadManager {
this.sendProgressUpdate(progress, status, game);
if (progress === 1) {
const isComplete = progress === 1 || download.status === "complete";
if (isComplete) {
await this.handleDownloadCompletion(download, game, gameId);
}
}
@@ -422,10 +423,10 @@ export class DownloadManager {
.values()
.all()
.then((games) =>
sortBy(
orderBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
["timestamp"],
["desc"]
)
);
@@ -499,18 +500,20 @@ export class DownloadManager {
}
static async cancelDownload(downloadKey = this.downloadingGameId) {
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Cancelling JS download");
this.jsDownloader.cancelDownload();
this.jsDownloader = null;
this.usingJsDownloader = false;
} else if (!this.isPreparingDownload) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
}
const isActiveDownload = downloadKey === this.downloadingGameId;
if (isActiveDownload) {
if (this.usingJsDownloader && this.jsDownloader) {
logger.log("[DownloadManager] Cancelling JS download");
this.jsDownloader.cancelDownload();
this.jsDownloader = null;
this.usingJsDownloader = false;
} else if (!this.isPreparingDownload) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
}
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
@@ -932,6 +935,20 @@ export class DownloadManager {
}
}
static async validateDownloadUrl(download: Download): Promise<void> {
const useJsDownloader = await this.shouldUseJsDownloader();
const isHttp = this.isHttpDownloader(download.downloader);
if (useJsDownloader && isHttp) {
const options = await this.getJsDownloadOptions(download);
if (!options) {
throw new Error("Failed to validate download URL");
}
} else if (isHttp) {
await this.getDownloadPayload(download);
}
}
static async startDownload(download: Download) {
const useJsDownloader = await this.shouldUseJsDownloader();
const isHttp = this.isHttpDownloader(download.downloader);

View File

@@ -320,10 +320,17 @@ export class JsHttpDownloader {
return null;
}
let progress = 0;
if (this.status === "complete") {
progress = 1;
} else if (this.fileSize > 0) {
progress = this.bytesDownloaded / this.fileSize;
}
return {
folderName: this.folderName,
fileSize: this.fileSize,
progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0,
progress,
downloadSpeed: this.downloadSpeed,
numPeers: 0,
numSeeds: 0,

View File

@@ -204,6 +204,9 @@ function onOpenGame(game: Game) {
lastSyncTick: now,
});
// Close the launcher window when game starts
WindowManager.closeGameLauncherWindow();
// Hide Hydra to tray on game startup if enabled
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",

View File

@@ -30,6 +30,7 @@ import { logger } from "./logger";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
public static gameLauncherWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
@@ -516,6 +517,84 @@ export class WindowManager {
}
}
private static readonly GAME_LAUNCHER_WINDOW_WIDTH = 550;
private static readonly GAME_LAUNCHER_WINDOW_HEIGHT = 320;
public static async createGameLauncherWindow(shop: string, objectId: string) {
if (this.gameLauncherWindow) {
this.gameLauncherWindow.close();
this.gameLauncherWindow = null;
}
const display = screen.getPrimaryDisplay();
const { width: displayWidth, height: displayHeight } = display.bounds;
const x = Math.round((displayWidth - this.GAME_LAUNCHER_WINDOW_WIDTH) / 2);
const y = Math.round(
(displayHeight - this.GAME_LAUNCHER_WINDOW_HEIGHT) / 2
);
this.gameLauncherWindow = new BrowserWindow({
width: this.GAME_LAUNCHER_WINDOW_WIDTH,
height: this.GAME_LAUNCHER_WINDOW_HEIGHT,
x,
y,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
frame: false,
backgroundColor: "#1c1c1c",
icon,
skipTaskbar: false,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.gameLauncherWindow.removeMenu();
this.loadWindowURL(
this.gameLauncherWindow,
`game-launcher?shop=${shop}&objectId=${objectId}`
);
this.gameLauncherWindow.on("closed", () => {
this.gameLauncherWindow = null;
});
if (!app.isPackaged || isStaging) {
this.gameLauncherWindow.webContents.openDevTools();
}
}
public static showGameLauncherWindow() {
if (this.gameLauncherWindow && !this.gameLauncherWindow.isDestroyed()) {
this.gameLauncherWindow.show();
}
}
public static closeGameLauncherWindow() {
if (this.gameLauncherWindow) {
this.gameLauncherWindow.close();
this.gameLauncherWindow = null;
}
}
public static openMainWindow() {
if (this.mainWindow) {
this.mainWindow.show();
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();
} else {
this.createMainWindow();
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash);

View File

@@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
startGameDownload: (payload: StartGameDownloadPayload) =>
ipcRenderer.invoke("startGameDownload", payload),
addGameToQueue: (payload: StartGameDownloadPayload) =>
ipcRenderer.invoke("addGameToQueue", payload),
cancelGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("cancelGameDownload", shop, objectId),
pauseGameDownload: (shop: GameShop, objectId: string) =>
@@ -37,6 +39,17 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameSeed", shop, objectId),
resumeGameSeed: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resumeGameSeed", shop, objectId),
updateDownloadQueuePosition: (
shop: GameShop,
objectId: string,
direction: "up" | "down"
) =>
ipcRenderer.invoke(
"updateDownloadQueuePosition",
shop,
objectId,
direction
),
onDownloadProgress: (cb: (value: DownloadProgress | null) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -492,6 +505,18 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("common-redist-progress", listener);
return () => ipcRenderer.removeListener("common-redist-progress", listener);
},
onPreflightProgress: (
cb: (value: { status: string; detail: string | null }) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: { status: string; detail: string | null }
) => cb(value);
ipcRenderer.on("preflight-progress", listener);
return () => ipcRenderer.removeListener("preflight-progress", listener);
},
resetCommonRedistPreflight: () =>
ipcRenderer.invoke("resetCommonRedistPreflight"),
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"),
@@ -675,6 +700,12 @@ contextBridge.exposeInMainWorld("electron", {
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
/* Game Launcher Window */
showGameLauncherWindow: () => ipcRenderer.invoke("showGameLauncherWindow"),
closeGameLauncherWindow: () => ipcRenderer.invoke("closeGameLauncherWindow"),
openMainWindow: () => ipcRenderer.invoke("openMainWindow"),
isMainWindowOpen: () => ipcRenderer.invoke("isMainWindowOpen"),
/* LevelDB Generic CRUD */
leveldb: {
get: (

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import { WorkWonders } from "workwonders-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -52,7 +52,7 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWondersSdk | null>(null);
const workwondersRef = useRef<WorkWonders | null>(null);
const {
hasActiveSubscription,
@@ -125,18 +125,18 @@ export function App() {
const parsedLocale =
possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en";
workwondersRef.current = new WorkWondersSdk();
workwondersRef.current = new WorkWonders();
await workwondersRef.current.init({
organization: "hydra",
token,
locale: parsedLocale,
});
await workwondersRef.current.initChangelogWidget();
workwondersRef.current.initChangelogWidgetMini();
await workwondersRef.current.changelog.initChangelogWidget();
workwondersRef.current.changelog.initChangelogWidgetMini();
if (token) {
workwondersRef.current.initFeedbackWidget();
workwondersRef.current.feedback.initFeedbackWidget();
}
},
[workwondersRef]

View File

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

View File

@@ -47,11 +47,19 @@ declare global {
startGameDownload: (
payload: StartGameDownloadPayload
) => Promise<{ ok: boolean; error?: string }>;
addGameToQueue: (
payload: StartGameDownloadPayload
) => Promise<{ ok: boolean; error?: string }>;
cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
updateDownloadQueuePosition: (
shop: GameShop,
objectId: string,
direction: "up" | "down"
) => Promise<boolean>;
onDownloadProgress: (
cb: (value: DownloadProgress | null) => void
) => () => Electron.IpcRenderer;
@@ -357,6 +365,10 @@ declare global {
onCommonRedistProgress: (
cb: (value: { log: string; complete: boolean }) => void
) => () => Electron.IpcRenderer;
onPreflightProgress: (
cb: (value: { status: string; detail: string | null }) => void
) => () => Electron.IpcRenderer;
resetCommonRedistPreflight: () => Promise<void>;
saveTempFile: (fileName: string, fileData: Uint8Array) => Promise<string>;
deleteTempFile: (filePath: string) => Promise<void>;
platform: NodeJS.Platform;
@@ -462,6 +474,12 @@ declare global {
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
/* Game Launcher Window */
showGameLauncherWindow: () => Promise<void>;
closeGameLauncherWindow: () => Promise<void>;
openMainWindow: () => Promise<void>;
isMainWindowOpen: () => Promise<boolean>;
/* Download Options */
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void

View File

@@ -38,6 +38,14 @@ export function useDownload() {
return response;
};
const addGameToQueue = async (payload: StartGameDownloadPayload) => {
const response = await window.electron.addGameToQueue(payload);
if (response.ok) updateLibrary();
return response;
};
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
@@ -61,10 +69,16 @@ export function useDownload() {
};
const cancelDownload = async (shop: GameShop, objectId: string) => {
await window.electron.cancelGameDownload(shop, objectId);
dispatch(clearDownload());
updateLibrary();
const gameId = `${shop}:${objectId}`;
const isActiveDownload = lastPacket?.gameId === gameId;
await window.electron.cancelGameDownload(shop, objectId);
if (isActiveDownload) {
dispatch(clearDownload());
}
updateLibrary();
removeGameInstaller(shop, objectId);
};
@@ -113,6 +127,7 @@ export function useDownload() {
lastPacket,
eta: calculateETA(),
startDownload,
addGameToQueue,
pauseDownload,
resumeDownload,
cancelDownload,

View File

@@ -34,6 +34,7 @@ import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
import GameLauncher from "./pages/game-launcher/game-launcher";
console.log = logger.log;
@@ -98,6 +99,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
path="/achievement-notification"
element={<AchievementNotification />}
/>
<Route path="/game-launcher" element={<GameLauncher />} />
</Routes>
</HashRouter>
</Provider>

View File

@@ -26,6 +26,8 @@ import {
DropdownMenuItem,
} from "@renderer/components/dropdown-menu/dropdown-menu";
import {
ArrowDownIcon,
ArrowUpIcon,
ClockIcon,
ColumnsIcon,
DownloadIcon,
@@ -40,6 +42,44 @@ import {
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js";
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "");
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
}
const r = Number.parseInt(h.substring(0, 2), 16) || 0;
const g = Number.parseInt(h.substring(2, 4), 16) || 0;
const b = Number.parseInt(h.substring(4, 6), 16) || 0;
return [r, g, b];
}
function isTooCloseRGB(a: string, b: string, threshold: number): boolean {
const [r1, g1, b1] = hexToRgb(a);
const [r2, g2, b2] = hexToRgb(b);
const distance = Math.sqrt(
Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2)
);
return distance < threshold;
}
const CHART_BACKGROUND_COLOR = "#1a1a1a";
const COLOR_DISTANCE_THRESHOLD = 28;
const FALLBACK_CHART_COLOR = "#fff";
function pickChartColor(dominant?: string): string {
if (!dominant || typeof dominant !== "string" || !dominant.startsWith("#")) {
return FALLBACK_CHART_COLOR;
}
if (
isTooCloseRGB(dominant, CHART_BACKGROUND_COLOR, COLOR_DISTANCE_THRESHOLD)
) {
return FALLBACK_CHART_COLOR;
}
return dominant;
}
interface AnimatedPercentageProps {
value: number;
}
@@ -442,6 +482,7 @@ export interface DownloadGroupProps {
openDeleteGameModal: (shop: GameShop, objectId: string) => void;
openGameInstaller: (shop: GameShop, objectId: string) => void;
seedingStatus: SeedingStatus[];
queuedGameIds?: string[];
}
export function DownloadGroup({
@@ -450,6 +491,7 @@ export function DownloadGroup({
openDeleteGameModal,
openGameInstaller,
seedingStatus,
queuedGameIds = [],
}: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
@@ -690,6 +732,18 @@ export function DownloadGroup({
setGameToCancelObjectId(null);
}, []);
const handleMoveInQueue = useCallback(
async (shop: GameShop, objectId: string, direction: "up" | "down") => {
await window.electron.updateDownloadQueuePosition(
shop,
objectId,
direction
);
updateLibrary();
},
[updateLibrary]
);
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const download = lastPacket?.download;
const isGameDownloading = isGameDownloadingMap[game.id];
@@ -765,7 +819,12 @@ export function DownloadGroup({
(download?.downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken);
return [
const queueIndex = queuedGameIds.indexOf(game.id);
const isFirstInQueue = queueIndex === 0;
const isLastInQueue = queueIndex === queuedGameIds.length - 1;
const isInQueue = queueIndex !== -1;
const actions = [
{
label: t("resume"),
disabled: isResumeDisabled,
@@ -774,6 +833,22 @@ export function DownloadGroup({
},
icon: <PlayIcon />,
},
{
label: t("move_up"),
show: isInQueue && !isFirstInQueue,
onClick: () => {
handleMoveInQueue(game.shop, game.objectId, "up");
},
icon: <ArrowUpIcon />,
},
{
label: t("move_down"),
show: isInQueue && !isLastInQueue,
onClick: () => {
handleMoveInQueue(game.shop, game.objectId, "down");
},
icon: <ArrowDownIcon />,
},
{
label: t("cancel"),
onClick: () => {
@@ -782,6 +857,8 @@ export function DownloadGroup({
icon: <XCircleIcon />,
},
];
return actions.filter((action) => action.show !== false);
};
const downloadInfo = useMemo(
@@ -863,7 +940,7 @@ export function DownloadGroup({
currentProgress = lastPacket.progress;
}
const dominantColor = dominantColors[game.id] || "#fff";
const dominantColor = pickChartColor(dominantColors[game.id]);
return (
<>

View File

@@ -103,18 +103,26 @@ export default function Downloads() {
};
}, [library, lastPacket?.gameId, extraction?.visibleId]);
const queuedGameIds = useMemo(
() => libraryGroup.queued.map((game) => game.id),
[libraryGroup.queued]
);
const downloadGroups = [
{
title: t("download_in_progress"),
library: libraryGroup.downloading,
queuedGameIds: [] as string[],
},
{
title: t("queued_downloads"),
library: libraryGroup.queued,
queuedGameIds,
},
{
title: t("downloads_completed"),
library: libraryGroup.complete,
queuedGameIds: [] as string[],
},
];
@@ -142,10 +150,11 @@ export default function Downloads() {
<DownloadGroup
key={group.title}
title={group.title}
library={orderBy(group.library, ["updatedAt"], ["desc"])}
library={group.library}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
seedingStatus={seedingStatus}
queuedGameIds={group.queuedGameIds}
/>
))}
</div>

View File

@@ -37,7 +37,7 @@ export default function GameDetails() {
const fromRandomizer = searchParams.get("fromRandomizer");
const gameTitle = searchParams.get("title");
const { startDownload } = useDownload();
const { startDownload, addGameToQueue } = useDownload();
const { t } = useTranslation("game_details");
@@ -100,17 +100,30 @@ export default function GameDetails() {
repack: GameRepack,
downloader: Downloader,
downloadPath: string,
automaticallyExtract: boolean
automaticallyExtract: boolean,
addToQueueOnly = false
) => {
const response = await startDownload({
objectId: objectId!,
title: gameTitle,
downloader,
shop,
downloadPath,
uri: selectRepackUri(repack, downloader),
automaticallyExtract: automaticallyExtract,
});
const response = addToQueueOnly
? await addGameToQueue({
objectId: objectId!,
title: gameTitle,
downloader,
shop,
downloadPath,
uri: selectRepackUri(repack, downloader),
automaticallyExtract: automaticallyExtract,
fileSize: repack.fileSize,
})
: await startDownload({
objectId: objectId!,
title: gameTitle,
downloader,
shop,
downloadPath,
uri: selectRepackUri(repack, downloader),
automaticallyExtract: automaticallyExtract,
fileSize: repack.fileSize,
});
if (response.ok) {
await updateGame();

View File

@@ -12,11 +12,17 @@ import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
PlusIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import {
useAppSelector,
useDownload,
useFeature,
useToast,
} from "@renderer/hooks";
import { motion } from "framer-motion";
import { Tooltip } from "react-tooltip";
import { RealDebridInfoModal } from "./real-debrid-info-modal";
@@ -29,7 +35,8 @@ export interface DownloadSettingsModalProps {
repack: GameRepack,
downloader: Downloader,
downloadPath: string,
automaticallyExtract: boolean
automaticallyExtract: boolean,
addToQueueOnly?: boolean
) => Promise<{ ok: boolean; error?: string }>;
repack: GameRepack | null;
}
@@ -46,8 +53,11 @@ export function DownloadSettingsModal({
(state) => state.userPreferences.value
);
const { lastPacket } = useDownload();
const { showErrorToast } = useToast();
const hasActiveDownload = lastPacket !== null;
const [diskFreeSpace, setDiskFreeSpace] = useState<number | null>(null);
const [selectedPath, setSelectedPath] = useState("");
const [downloadStarting, setDownloadStarting] = useState(false);
@@ -220,7 +230,8 @@ export function DownloadSettingsModal({
repack,
selectedDownloader!,
selectedPath,
automaticExtractionEnabled
automaticExtractionEnabled,
hasActiveDownload
);
if (response.ok) {
@@ -456,6 +467,11 @@ export function DownloadSettingsModal({
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : hasActiveDownload ? (
<>
<PlusIcon />
{t("add_to_queue")}
</>
) : (
<>
<DownloadIcon />

View File

@@ -39,7 +39,8 @@ export interface RepacksModalProps {
repack: GameRepack,
downloader: Downloader,
downloadPath: string,
automaticallyExtract: boolean
automaticallyExtract: boolean,
addToQueueOnly?: boolean
) => Promise<{ ok: boolean; error?: string }>;
onClose: () => void;
}

View File

@@ -0,0 +1,231 @@
@use "../../scss/globals.scss";
.game-launcher {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: linear-gradient(135deg, #0d0d0d 0%, #1a1a2e 50%, #16213e 100%);
-webkit-app-region: drag;
padding: calc(globals.$spacing-unit * 3);
box-sizing: border-box;
position: relative;
overflow: hidden;
&__background {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(20px);
transform: scale(1.1);
z-index: 0;
}
&__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(0, 0, 0, 0.75) 0%,
rgba(0, 0, 0, 0.85) 50%,
rgba(0, 0, 0, 0.9) 100%
);
pointer-events: none;
z-index: 1;
}
&__glow {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
ellipse at top right,
rgba(22, 177, 149, 0.15) 0%,
transparent 50%
);
pointer-events: none;
z-index: 2;
}
&__logo-badge {
position: absolute;
top: calc(globals.$spacing-unit * 2);
right: calc(globals.$spacing-unit * 2);
z-index: 10;
svg {
width: 28px;
height: 28px;
fill: globals.$body-color;
opacity: 0.6;
}
}
&__content {
display: flex;
flex: 1;
gap: calc(globals.$spacing-unit * 2);
-webkit-app-region: no-drag;
min-height: 0;
position: relative;
z-index: 5;
}
&__cover {
height: 100%;
width: auto;
max-width: 180px;
border-radius: 8px;
object-fit: contain;
flex-shrink: 0;
background-color: globals.$dark-background-color;
}
&__cover-placeholder {
height: 100%;
width: 180px;
border-radius: 8px;
background-color: globals.$dark-background-color;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: globals.$muted-color;
}
&__info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
&__center {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
gap: calc(globals.$spacing-unit);
}
&__title {
font-size: 22px;
font-weight: 700;
color: globals.$body-color;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
padding-right: 40px;
}
&__status {
font-size: 14px;
color: globals.$muted-color;
margin: 0;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
}
&__spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: globals.$muted-color;
border-right-color: globals.$muted-color;
border-radius: 50%;
animation: spinner-rotate 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
flex-shrink: 0;
}
@keyframes spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&__dots {
display: inline-block;
width: 20px;
&::after {
content: "";
animation: dots 1.5s steps(4, end) infinite;
}
}
@keyframes dots {
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75% {
content: "...";
}
100% {
content: "";
}
}
&__stats {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding-top: calc(globals.$spacing-unit * 2);
}
&__stat {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
&__button {
width: 100%;
background-color: globals.$muted-color;
border: solid 1px transparent;
color: #0d0d0d;
border-radius: 8px;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
min-height: 40px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all ease 0.2s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: #dadbe1;
}
&:active {
opacity: globals.$active-opacity;
}
}
}

View File

@@ -0,0 +1,289 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ImageIcon, ClockIcon, TrophyIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { darkenColor } from "@renderer/helpers";
import { logger } from "@renderer/logger";
import { average } from "color.js";
import type { Game, GameShop, ShopAssets } from "@types";
import "./game-launcher.scss";
type PreflightStatus =
| "idle"
| "checking"
| "downloading"
| "installing"
| "complete"
| "error";
export default function GameLauncher() {
const { t } = useTranslation("game_launcher");
const [searchParams] = useSearchParams();
const shop = searchParams.get("shop") as GameShop;
const objectId = searchParams.get("objectId");
const [game, setGame] = useState<Game | null>(null);
const [gameAssets, setGameAssets] = useState<ShopAssets | null>(null);
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [accentColor, setAccentColor] = useState<string | null>(null);
const [colorExtracted, setColorExtracted] = useState(false);
const [colorError, setColorError] = useState(false);
const [windowShown, setWindowShown] = useState(false);
const [isMainWindowOpen, setIsMainWindowOpen] = useState(false);
const [preflightStatus, setPreflightStatus] =
useState<PreflightStatus>("idle");
const [preflightDetail, setPreflightDetail] = useState<string | null>(null);
const [preflightStarted, setPreflightStarted] = useState(false);
const formatPlayTime = useCallback(
(playTimeInMilliseconds = 0) => {
const minutes = playTimeInMilliseconds / 60000;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes_short", { amount: minutes.toFixed(0) });
}
const hours = minutes / 60;
return t("amount_hours_short", { amount: hours.toFixed(1) });
},
[t]
);
useEffect(() => {
if (shop && objectId) {
window.electron.getGameByObjectId(shop, objectId).then((gameData) => {
setGame(gameData);
});
window.electron.getGameAssets(objectId, shop).then((assets) => {
setGameAssets(assets);
});
}
window.electron.isMainWindowOpen().then((isOpen) => {
setIsMainWindowOpen(isOpen);
});
}, [shop, objectId]);
useEffect(() => {
if (!window.electron.onPreflightProgress) {
return;
}
const unsubscribe = window.electron.onPreflightProgress(
({ status, detail }) => {
setPreflightStarted(true);
setPreflightStatus(status as PreflightStatus);
setPreflightDetail(detail);
}
);
return () => unsubscribe();
}, []);
// Auto-close timer - only starts after preflight completes
// Preflight is "done" when: it completed/errored, OR it never started (non-Windows or no preflight needed)
const isPreflightDone =
preflightStatus === "complete" || preflightStatus === "error";
// If preflight hasn't started after 3 seconds, assume it's not running (e.g., non-Windows)
const [preflightTimeout, setPreflightTimeout] = useState(false);
useEffect(() => {
if (preflightStarted) return;
const timer = setTimeout(() => {
setPreflightTimeout(true);
}, 3000);
return () => clearTimeout(timer);
}, [preflightStarted]);
const canAutoClose =
isPreflightDone || (!preflightStarted && preflightTimeout);
useEffect(() => {
// Don't start timer until window is shown AND preflight is done
if (!windowShown || !canAutoClose) return;
const timer = setTimeout(() => {
window.electron.closeGameLauncherWindow();
}, 5000);
return () => clearTimeout(timer);
}, [windowShown, canAutoClose]);
const handleOpenHydra = () => {
window.electron.openMainWindow();
window.electron.closeGameLauncherWindow();
};
const coverImage =
gameAssets?.coverImageUrl?.replaceAll("\\", "/") ||
game?.iconUrl?.replaceAll("\\", "/") ||
game?.libraryHeroImageUrl?.replaceAll("\\", "/") ||
"";
const gameTitle = game?.title ?? gameAssets?.title ?? "";
const playTime = game?.playTimeInMilliseconds ?? 0;
const achievementCount = game?.achievementCount ?? 0;
const unlockedAchievements = game?.unlockedAchievementCount ?? 0;
const extractAccentColor = useCallback(async (imageUrl: string) => {
try {
const color = await average(imageUrl, { amount: 1, format: "hex" });
const colorString = typeof color === "string" ? color : color.toString();
setAccentColor(colorString);
} catch (error) {
logger.error("Failed to extract accent color:", error);
setColorError(true);
} finally {
setColorExtracted(true);
}
}, []);
const getStatusMessage = useCallback(() => {
switch (preflightStatus) {
case "checking":
return t("preflight_checking");
case "downloading":
return t("preflight_downloading");
case "installing":
return preflightDetail
? t("preflight_installing_detail", { detail: preflightDetail })
: t("preflight_installing");
case "complete":
case "error":
case "idle":
default:
return t("launching_base");
}
}, [preflightStatus, preflightDetail, t]);
const isPreflightRunning =
preflightStatus === "checking" ||
preflightStatus === "downloading" ||
preflightStatus === "installing";
useEffect(() => {
if (coverImage && !colorExtracted) {
extractAccentColor(coverImage);
}
}, [coverImage, colorExtracted, extractAccentColor]);
const isReady = imageLoaded && colorExtracted && !colorError;
const hasFailed =
imageError || colorError || (!coverImage && gameAssets !== null);
useEffect(() => {
if (windowShown) return;
if (hasFailed) {
window.electron.closeGameLauncherWindow();
return;
}
if (isReady) {
window.electron.showGameLauncherWindow();
setWindowShown(true);
}
}, [isReady, hasFailed, windowShown]);
const backgroundStyle = accentColor
? {
background: `linear-gradient(135deg, ${darkenColor(accentColor, 0.7)} 0%, ${darkenColor(accentColor, 0.8, 0.9)} 50%, ${darkenColor(accentColor, 0.85, 0.8)} 100%)`,
}
: undefined;
const glowStyle = accentColor
? {
background: `radial-gradient(ellipse at top right, ${darkenColor(accentColor, 0.3, 0.15)} 0%, transparent 50%)`,
}
: undefined;
return (
<div className="game-launcher" style={backgroundStyle}>
{coverImage && (
<div
className="game-launcher__background"
style={{ backgroundImage: `url(${coverImage})` }}
/>
)}
<div className="game-launcher__overlay" />
<div className="game-launcher__glow" style={glowStyle} />
<div className="game-launcher__logo-badge">
<HydraIcon />
</div>
<div className="game-launcher__content">
{imageError || !coverImage ? (
<div className="game-launcher__cover-placeholder">
<ImageIcon size={32} />
</div>
) : (
<>
{!isReady && (
<div className="game-launcher__cover-placeholder">
<ImageIcon size={32} />
</div>
)}
<img
src={coverImage}
alt={gameTitle}
className="game-launcher__cover"
style={{ display: isReady ? "block" : "none" }}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
)}
<div className="game-launcher__info">
<div className="game-launcher__center">
<h1 className="game-launcher__title">{gameTitle}</h1>
<p className="game-launcher__status">
{isPreflightRunning && (
<span className="game-launcher__spinner" />
)}
{getStatusMessage()}
<span className="game-launcher__dots" />
</p>
{!isMainWindowOpen && (
<button
type="button"
className="game-launcher__button"
onClick={handleOpenHydra}
>
{t("open_hydra")}
</button>
)}
</div>
{(playTime > 0 || achievementCount > 0) && (
<div className="game-launcher__stats">
{playTime > 0 && (
<span className="game-launcher__stat">
<ClockIcon size={14} />
{formatPlayTime(playTime)}
</span>
)}
{achievementCount > 0 && (
<span className="game-launcher__stat">
<TrophyIcon size={14} />
{unlockedAchievements}/{achievementCount}
</span>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -51,6 +51,25 @@ export const formatBytes = (bytes: number): string => {
return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`;
};
export const parseBytes = (sizeString: string | null): number | null => {
if (!sizeString) return null;
const regex = /^([\d.,]+)\s*([A-Za-z]+)$/;
const match = regex.exec(sizeString.trim());
if (!match) return null;
const value = Number.parseFloat(match[1].replaceAll(",", "."));
const unit = match[2].toUpperCase();
if (Number.isNaN(value)) return null;
const unitIndex = FORMAT.indexOf(unit);
if (unitIndex === -1) return null;
const byteKBase = 1024;
return Math.round(value * Math.pow(byteKBase, unitIndex));
};
export const formatBytesToMbps = (bytesPerSecond: number): string => {
const bitsPerSecond = bytesPerSecond * 8;
const mbps = bitsPerSecond / (1024 * 1024);

View File

@@ -118,6 +118,7 @@ export interface StartGameDownloadPayload {
downloadPath: string;
downloader: Downloader;
automaticallyExtract: boolean;
fileSize?: string | null;
}
export interface UserFriend {

View File

@@ -3383,6 +3383,13 @@
dependencies:
undici-types "~6.21.0"
"@types/node@^22.10.3":
version "22.19.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.7.tgz#434094ee1731ae76c16083008590a5835a8c39c1"
integrity sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==
dependencies:
undici-types "~6.21.0"
"@types/node@^22.7.7":
version "22.18.12"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.12.tgz#e165d87bc25d7bf6d3657035c914db7485de84fb"
@@ -6464,10 +6471,10 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies:
json-buffer "3.0.1"
ky@^1.11.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.14.1.tgz#16f20b3bf3939abcc04e2a9613f47360fe5f64c9"
integrity sha512-hYje4L9JCmpEQBtudo+v52X5X8tgWXUYyPcxKSuxQNboqufecl9VMWjGiucAFH060AwPXHZuH+WB2rrqfkmafw==
ky@^1.14.2:
version "1.14.2"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.14.2.tgz#385d6d05d2825502e68898ace125124e6fe7357d"
integrity sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==
language-subtag-registry@^0.3.20:
version "0.3.23"
@@ -7367,6 +7374,20 @@ plist@3.1.0, plist@^3.0.4, plist@^3.0.5, plist@^3.1.0:
base64-js "^1.5.1"
xmlbuilder "^15.1.1"
png-to-ico@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/png-to-ico/-/png-to-ico-3.0.1.tgz#6ad50bec9ffa40aa74265deadc5128fa4097dfbe"
integrity sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==
dependencies:
"@types/node" "^22.10.3"
minimist "^1.2.8"
pngjs "^7.0.0"
pngjs@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26"
integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==
possible-typed-array-names@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
@@ -8681,10 +8702,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1:
mkdirp "^1.0.3"
yallist "^4.0.0"
tar@^7.5.2:
version "7.5.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc"
integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==
tar@^7.5.4:
version "7.5.4"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.4.tgz#18b53b44f939a7e03ed874f1fafe17d29e306c81"
integrity sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==
dependencies:
"@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0"
@@ -9238,12 +9259,12 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
workwonders-sdk@0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/workwonders-sdk/-/workwonders-sdk-0.0.10.tgz#377167370a39c905c5228f8972c37c19004b7b21"
integrity sha512-bnswhlLRz1TCiqGV8l+VEOBej7u1SAkzLMEv6A60Sp0+S4j4pnmSve92KeOts/GYtUeNDuNM7fLPwZwMKY3sAg==
workwonders-sdk@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/workwonders-sdk/-/workwonders-sdk-0.1.1.tgz#7ac0eb3d9ef0a5a8cc5ca4e6f5e387e29875faa9"
integrity sha512-PEsl33QCeiBlYed/MmnX1unnd4Kn7vzVIza00HQ/5Zsan89nqnwWx9vqgJnNipXkkmIWl8oDL9bGRNjtL4XZ4Q==
dependencies:
ky "^1.11.0"
ky "^1.14.2"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"