Merge branch 'rc/v2.0' into feat/show-toast-after-create-shortcut

This commit is contained in:
Zamitto 2024-06-22 01:59:42 -03:00
commit dcdc6a7114
93 changed files with 2338 additions and 604 deletions

View File

@ -1,6 +1,6 @@
name: Build
on: [pull_request]
on: pull_request
jobs:
build:
@ -28,6 +28,7 @@ jobs:
env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
@ -36,6 +37,7 @@ jobs:
env:
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact

View File

@ -1,6 +1,6 @@
name: Lint
on: [pull_request]
on: [pull_request, push]
jobs:
lint:

View File

@ -5,7 +5,9 @@ directories:
extraResources:
- aria2
- seeds
- fastlist.exe
- from: node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe
to: fastlist.exe
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
files:
- "!**/.vscode/*"
- "!src/*"

View File

@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "1.2.4",
"version": "2.0.0",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@ -53,12 +53,14 @@
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"fetch-cookie": "^3.0.1",
"file-type": "^19.0.0",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"icojs": "^0.19.3",
"iso-639-1": "3.1.2",
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16",
@ -81,7 +83,9 @@
"@electron-toolkit/tsconfig": "^1.0.1",
"@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6",
"@types/jsdom": "^21.1.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.12.7",
"@types/parse-torrent": "^5.8.7",

View File

@ -47,11 +47,4 @@ const downloadAria2 = async () => {
});
};
if (process.platform === "win32") {
fs.copyFileSync(
"node_modules/ps-list/vendor/fastlist-0.3.0-x64.exe",
"fastlist.exe"
);
}
downloadAria2();

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "مميّز",
"recently_added": "مضاف مؤخراً",
"trending": "شائع",
"surprise_me": "فاجئني",
"no_results": "لم يتم العثور على نتائج"
@ -15,12 +14,7 @@
"paused": "{{title}} (متوقف)",
"downloading": "{{title}} ({{percentage}} - جارٍ التنزيل...)",
"filter": "بحث في المكتبة",
"follow_us": "تابعنا",
"home": "الرئيسية",
"discord": "انضم إلى الـDiscord الخاص بنا",
"telegram": "انضم إلى قناة Telegram الخاصة بنا",
"x": "تابعنا على X",
"github": "ساهم في مشروعنا على GitHub"
"home": "الرئيسية"
},
"header": {
"search": "ابحث عن الألعاب",
@ -50,7 +44,6 @@
"pause": "إيقاف",
"cancel": "إلغاء",
"remove": "إزالة",
"remove_from_list": "إزالة",
"space_left_on_disk": "{{space}} متبقية على القرص",
"eta": "الوقت المتبقي {{eta}}",
"downloading_metadata": "جاري تنزيل البيانات الوصفية...",
@ -58,12 +51,8 @@
"requirements": "متطلبات النظام",
"minimum": "الحد الأدنى",
"recommended": "موصى به",
"no_minimum_requirements": "{{title}} لا تتوفر معلومات عن الحد الأدنى للمتطلبات",
"no_recommended_requirements": "{{title}} لا تتوفر معلومات عن المتطلبات الموصى بها",
"release_date": "تم الإصدار في {{date}}",
"publisher": "نشر بواسطة {{publisher}}",
"copy_link_to_clipboard": "نسخ الرابط",
"copied_link_to_clipboard": "تم نسخ الرابط",
"hours": "ساعات",
"minutes": "دقائق",
"amount_hours": "{{amount}} ساعات",
@ -84,14 +73,6 @@
"repacks_modal_description": "اختر الحزمة التي تريد تنزيلها",
"select_folder_hint": "لتغيير المجلد الافتراضي، انتقل إلى الإعدادات",
"download_now": "تنزيل الآن",
"installation_instructions": "إرشادات التثبيت",
"installation_instructions_description": "هناك خطوات إضافية مطلوبة لتثبيت هذه اللعبة",
"online_fix_instruction": "تتطلب ألعاب OnlineFix كلمة مرور لاستخراجها. عند الحاجة، استخدم كلمة المرور التالية:",
"dodi_installation_instruction": "عند فتح مثبت DODI، اضغط على مفتاح التشغيل لأعلى <0 /> لبدء عملية التثبيت:",
"dont_show_it_again": "لا تعرضها مرة أخرى",
"copy_to_clipboard": "نسخ",
"copied_to_clipboard": "تم النسخ",
"got_it": "حسنأ",
"no_shop_details": "لم يتم استرداد تفاصيل المتجر.",
"download_options": "خيارات التنزيل",
"download_path": "مسار التنزيل",
@ -114,17 +95,13 @@
"eta": "الوقت المتبقي {{eta}}",
"paused": "متوقفة مؤقتًا",
"verifying": "جار التحقق…",
"completed_at": "اكتمل في {{date}}",
"completed": "اكتمل",
"download_again": "تحميل مرة أخرى",
"cancel": "إلغاء",
"filter": "تصفية الألعاب التي تم تنزيلها",
"remove": "إزالة",
"downloading_metadata": "جار تنزيل البيانات الوصفية…",
"starting_download": "يبدأ التنزيل…",
"deleting": "جار حذف المثبت…",
"delete": "إزالة المثبت",
"remove_from_list": "إزالة",
"delete_modal_title": "هل أنت متأكد؟",
"delete_modal_description": "سيؤدي هذا إلى إزالة جميع ملفات التثبيت من جهاز الكمبيوتر الخاص بك",
"install": "تثبيت"
@ -135,8 +112,6 @@
"notifications": "الإشعارات",
"enable_download_notifications": "عند اكتمال التنزيل",
"enable_repack_list_notifications": "عند إضافة حزمة جديدة",
"telemetry": "القياس عن بعد",
"telemetry_description": "تفعيل إحصائيات الاستخدام مجهولة المصدر",
"real_debrid_api_token_label": "رمز واجهة برمجة التطبيقات (API) لـReal-Debrid ",
"quit_app_instead_hiding": "إنهاء هايدرا بدلاً من التصغير الى شريط الحالة",
"launch_with_system": "تشغيل هايدرا عند بدء تشغيل النظام",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Рэкамэндаванае",
"recently_added": "Нядаўна дададзенае",
"trending": "Актуальнае",
"surprise_me": "Здзіві мяне",
"no_results": "Няма вынікаў"
@ -15,12 +14,7 @@
"paused": "{{title}} (Спынена)",
"downloading": "{{title}} ({{percentage}} - Сцягванне…)",
"filter": "Фільтар бібліятэкі",
"follow_us": "Падпісвайцеся на нас",
"home": "Галоўная",
"discord": "Далучайцеся да Discord",
"telegram": "Далучайцеся да Telegram",
"x": "Падпісвайцеся на X",
"github": "Зрабіць свой унёсак на GitHub"
"home": "Галоўная"
},
"header": {
"search": "Пошук",
@ -50,7 +44,6 @@
"pause": "Спыніць",
"cancel": "Скасаваць",
"remove": "Выдаліць",
"remove_from_list": "Выдаліць",
"space_left_on_disk": "{{space}} засталося на дыску",
"eta": "Канчатак {{eta}}",
"downloading_metadata": "Сцягванне мэтаданых…",
@ -58,12 +51,8 @@
"requirements": "Сістэмныя патрэбаванни",
"minimum": "Мінімальныя",
"recommended": "Рэкамендуемыя",
"no_minimum_requirements": "{{title}} ня ўтрымлівае інфармацыі пра мінімальныя патрабаванні",
"no_recommended_requirements": "{{title}} ня ўтрымлівае інфармацыі пра рэкамендуемыя патрабаванні",
"release_date": "Выпушчана {{date}}",
"publisher": "Выдана {{publisher}}",
"copy_link_to_clipboard": "Скапіяваць спасылку",
"copied_link_to_clipboard": "Спасылка скапіявана",
"hours": "гадзін",
"minutes": "хвілін",
"amount_hours": "{{amount}} гадзін",
@ -83,15 +72,7 @@
"change": "Змяніць",
"repacks_modal_description": "Абярыце рэпак, які хочаце сцягнуць",
"select_folder_hint": "Каб змяніць папку па змоўчанні, адкрыйце",
"download_now": "Сцягнуць зараз",
"installation_instructions": "Інструкцыя ўсталёўкі",
"installation_instructions_description": "Усталёўка гэтай гульні патрабуе дадатковых крокаў",
"online_fix_instruction": "Гульні з OnlineFix патрабуюць пароль для вымання. Калі неабходна, выкарыстоўвайце наступны пароль:",
"dodi_installation_instruction": "Калі вы адкрыеце ўсталёўшчык DODI, націсніце на клявіятуры клявішу 'уверх' <0 />, каб пачаць працэс усталёўкі:",
"dont_show_it_again": "Не паказваць зноў",
"copy_to_clipboard": "Капіяваць",
"copied_to_clipboard": "Скапіявана",
"got_it": "Зразумела"
"download_now": "Сцягнуць зараз"
},
"activation": {
"title": "Актываваць Hydra",
@ -107,17 +88,13 @@
"eta": "Канчатак {{eta}}",
"paused": "Спынена",
"verifying": "Праверка…",
"completed_at": "Скончана а {{date}}",
"completed": "Скончана",
"download_again": "Сцягнуць зноў",
"cancel": "Скасаваць",
"filter": "Фільтар сцягнутых гульняў",
"remove": "Выдаліць",
"downloading_metadata": "Сцягванне мэтаданых…",
"starting_download": "Пачатак сцягвання…",
"deleting": "Выдаленне ўсталёўшчыка…",
"delete": "Выдаліць усталёўшчык",
"remove_from_list": "Выдаліць",
"delete_modal_title": "Вы ўпэўнены?",
"delete_modal_description": "Гэта выдаліць усе файлы ўсталёвак з вашага кампутара",
"install": "Усталяваць"
@ -128,8 +105,6 @@
"notifications": "Апавяшчэнні",
"enable_download_notifications": "Па сканчэнні сцягванні",
"enable_repack_list_notifications": "Пры даданні новага рэпака",
"telemetry": "Тэлеметрыя",
"telemetry_description": "Уключыць ананімную статыстыку выкарыстання",
"behavior": "Паводзіны",
"quit_app_instead_hiding": "Закрываць праграму замест таго, каб хаваць яе ў трэй",
"launch_with_system": "Запускаць праграму пры запуску сыстэмы"

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Anbefalet",
"recently_added": "Nyligt tilføjet",
"trending": "Trender",
"surprise_me": "Overrask mig",
"no_results": "Ingen resultater fundet"
@ -15,12 +14,7 @@
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filtrer bibliotek",
"follow_us": "Følg os",
"home": "Hjem",
"discord": "Tilslut dig vores Discord",
"telegram": "Tilslut dig vores Telegram",
"x": "Følg på X",
"github": "Bidrag på GitHub"
"home": "Hjem"
},
"header": {
"search": "Søg spil",
@ -50,7 +44,6 @@
"pause": "Pause",
"cancel": "Annullér",
"remove": "Fjern",
"remove_from_list": "Fjern",
"space_left_on_disk": "{{space}} tilbage på harddisken",
"eta": "Konklusion {{eta}}",
"downloading_metadata": "Downloader metadata…",
@ -58,12 +51,8 @@
"requirements": "System behov",
"minimum": "Mindste",
"recommended": "Anbefalet",
"no_minimum_requirements": "{{title}} angiver ikke mindste behov informationer",
"no_recommended_requirements": "{{title}} angiver ikke anbefalet behov informationer",
"release_date": "Offentliggjort den {{date}}",
"publisher": "Udgivet af {{publisher}}",
"copy_link_to_clipboard": "Kopier link",
"copied_link_to_clipboard": "Link kopieret",
"hours": "timer",
"minutes": "minutter",
"amount_hours": "{{amount}} timer",
@ -83,15 +72,7 @@
"change": "Ændré",
"repacks_modal_description": "Vælg den repack du vil downloade",
"select_folder_hint": "For at ændre standard mappen, gå til <0>Instillingerne</0>",
"download_now": "Download nu",
"installation_instructions": "Installations Instrukser",
"installation_instructions_description": "Yderligere skridt er krævet for at installere dette spil",
"online_fix_instruction": "OnlineFix spil kræver et kodeord for at kunne blive udpakket. Når krævet, brug det følgende kodeord:",
"dodi_installation_instruction": "Når du åbner DODI installatør, tryk på op-knappen på dit tastatur <0 /> for at starte installations processen:",
"dont_show_it_again": "Vis ikke igen",
"copy_to_clipboard": "Kopier",
"copied_to_clipboard": "Kopieret",
"got_it": "Forstået"
"download_now": "Download nu"
},
"activation": {
"title": "Aktivér Hydra",
@ -107,17 +88,13 @@
"eta": "Konklusion {{eta}}",
"paused": "Pauset",
"verifying": "Verificerer…",
"completed_at": "Færdiggjort på {{date}}",
"completed": "Færdigt",
"download_again": "Download igen",
"cancel": "Annullér",
"filter": "Filtrer downloadet spil",
"remove": "Fjern",
"downloading_metadata": "Downloader metadata…",
"starting_download": "Starter download…",
"deleting": "Sletter installatør…",
"delete": "Fjern installatør",
"remove_from_list": "Fjern",
"delete_modal_title": "Er du sikker?",
"delete_modal_description": "Dette vil fjerne alle installations filerne fra din computer",
"install": "Installér"
@ -128,8 +105,6 @@
"notifications": "Notifikationer",
"enable_download_notifications": "Når et download bliver færdigt",
"enable_repack_list_notifications": "Når en ny repack bliver tilføjet",
"telemetry": "Telemetri",
"telemetry_description": "Slå anonymt brugs statistik til",
"quit_app_instead_hiding": "Afslut Hydra instedet for at minimere til processlinjen",
"launch_with_system": "Åben Hydra ved start af systemet",
"general": "Generelt",

View File

@ -1,4 +1,7 @@
{
"app": {
"successfully_signed_in": "Successfully signed in"
},
"home": {
"featured": "Featured",
"trending": "Trending",
@ -16,7 +19,8 @@
"filter": "Filter library",
"home": "Home",
"queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected"
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in"
},
"header": {
"search": "Search games",
@ -49,7 +53,6 @@
"pause": "Pause",
"cancel": "Cancel",
"remove": "Remove",
"remove_from_list": "Remove",
"space_left_on_disk": "{{space}} left on disk",
"eta": "Conclusion {{eta}}",
"calculating_eta": "Calculating remaining time…",
@ -58,13 +61,9 @@
"requirements": "System requirements",
"minimum": "Minimum",
"recommended": "Recommended",
"no_minimum_requirements": "{{title}} doesn't provide minimum requirements information",
"no_recommended_requirements": "{{title}} doesn't provide recommended requirements information",
"paused": "Paused",
"release_date": "Released on {{date}}",
"publisher": "Published by {{publisher}}",
"copy_link_to_clipboard": "Copy link",
"copied_link_to_clipboard": "Link copied",
"hours": "hours",
"minutes": "minutes",
"amount_hours": "{{amount}} hours",
@ -85,14 +84,6 @@
"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",
"installation_instructions": "Installation Instructions",
"installation_instructions_description": "Additional steps are required to install this game",
"online_fix_instruction": "OnlineFix games requires a password to be extracted. When required, use the following password:",
"dodi_installation_instruction": "When you open DODI installer, press your keyboard up key <0 /> to start the installation process:",
"dont_show_it_again": "Don't show it again",
"copy_to_clipboard": "Copy",
"copied_to_clipboard": "Copied",
"got_it": "Got it",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
"download_path": "Download path",
@ -137,18 +128,14 @@
"eta": "Conclusion {{eta}}",
"paused": "Paused",
"verifying": "Verifying…",
"completed_at": "Completed in {{date}}",
"completed": "Completed",
"removed": "Not downloaded",
"download_again": "Download again",
"cancel": "Cancel",
"filter": "Filter downloaded games",
"remove": "Remove",
"downloading_metadata": "Downloading metadata…",
"starting_download": "Starting download…",
"deleting": "Deleting installer…",
"delete": "Remove installer",
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install",
@ -165,8 +152,6 @@
"notifications": "Notifications",
"enable_download_notifications": "When a download is complete",
"enable_repack_list_notifications": "When a new repack is added",
"telemetry": "Telemetry",
"telemetry_description": "Enable anonymous usage statistics",
"real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "Don't hide Hydra when closing",
"launch_with_system": "Launch Hydra on system start-up",
@ -200,7 +185,12 @@
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"added_download_source": "Added download source",
"download_sources_synced": "All download sources are synced"
"download_sources_synced": "All download sources are synced",
"insert_valid_json_url": "Insert a valid JSON url",
"found_download_option_zero": "No download option found",
"found_download_option_one": "Found {{countFormatted}} download option",
"found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import"
},
"notifications": {
"download_complete": "Download complete",
@ -226,5 +216,27 @@
},
"forms": {
"toggle_password_visibility": "Toggle password visibility"
},
"user_profile": {
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"last_time_played": "Last played {{period}}",
"activity": "Recent activity",
"library": "Library",
"total_play_time": "Total playtime: {{amount}}",
"no_recent_activity_title": "Hmmm… nothing here",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
"display_name": "Display name",
"saving": "Saving",
"save": "Save",
"edit_profile": "Edit Profile",
"saved_successfully": "Saved successfully",
"try_again": "Please, try again",
"sign_out_modal_title": "Are you sure?",
"cancel": "Cancel",
"successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out",
"playing_for": "Playing for {{amount}}",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?"
}
}

View File

@ -1,4 +1,7 @@
{
"app": {
"successfully_signed_in": "Successfully signed in (TRANSLATE ME)"
},
"home": {
"featured": "Destacado",
"trending": "Tendencias",
@ -16,7 +19,8 @@
"filter": "Buscar en la biblioteca",
"home": "Inicio",
"queued": "{{title}} (En Cola)",
"game_has_no_executable": "El juego no tiene un ejecutable"
"game_has_no_executable": "El juego no tiene un ejecutable",
"sign_in": "Sign in (TRANSLATE ME)"
},
"header": {
"search": "Buscar juegos",
@ -49,7 +53,6 @@
"pause": "Pausa",
"cancel": "Cancelar",
"remove": "Eliminar",
"remove_from_list": "Quitar",
"space_left_on_disk": "{{space}} restantes en el disco",
"eta": "Tiempo restante: {{eta}}",
"calculating_eta": "Calculando tiempo restante…",
@ -58,13 +61,9 @@
"requirements": "Requisitos del Sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
"no_minimum_requirements": "Sin requisitos mínimos para {{title}}",
"no_recommended_requirements": "{{title}} no tiene requisitos recomendados",
"paused": "Pausado",
"release_date": "Fecha de lanzamiento: {{date}}",
"publisher": "Publicado por: {{publisher}}",
"copy_link_to_clipboard": "Copiar enlace",
"copied_link_to_clipboard": "Enlace copiado",
"hours": "horas",
"minutes": "minutos",
"amount_hours": "{{amount}} horas",
@ -85,14 +84,6 @@
"repacks_modal_description": "Selecciona el repack que quieres descargar",
"select_folder_hint": "Para cambiar la carpeta predeterminada, ve a <0>Ajustes</0>",
"download_now": "Descargar ahora",
"installation_instructions": "Instrucciones de instalación",
"installation_instructions_description": "Se requieren de pasos adicionales para instalar este juego",
"online_fix_instruction": "Los juegos de OnlineFix requieren una contraseña para ser extraídos. Cuando se requiera, usa la siguiente contraseña:",
"dodi_installation_instruction": "Cuando abras el instalador de DODI, presiona la tecla hacia arriba del teclado <0 /> para iniciar el proceso de instalación:",
"dont_show_it_again": "No mostrar de nuevo",
"copy_to_clipboard": "Copiar",
"copied_to_clipboard": "Copiado",
"got_it": "Entendido",
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga",
"download_path": "Ruta de descarga",
@ -135,18 +126,14 @@
"eta": "Finalizando en {{eta}}",
"paused": "En Pausa",
"verifying": "Verificando…",
"completed_at": "Completado el {{date}}",
"completed": "Completado",
"removed": "No descargado",
"download_again": "Descargar de nuevo",
"cancel": "Cancelar",
"filter": "Buscar juegos descargados",
"remove": "Eliminar",
"downloading_metadata": "Descargando metadatos…",
"starting_download": "Iniciando descarga…",
"deleting": "Eliminando instalador…",
"delete": "Eliminar instalador",
"remove_from_list": "Eliminar",
"delete_modal_title": "¿Estás seguro?",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.",
"install": "Instalar",
@ -163,8 +150,6 @@
"notifications": "Notificaciones",
"enable_download_notifications": "Cuando se completa una descarga",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo",
"telemetry": "Telemetría",
"telemetry_description": "Habilitar recopilación de datos de manera anónima",
"real_debrid_api_token_label": "Token API de Real-Debrid",
"quit_app_instead_hiding": "Salir de Hydra en vez de minimizar en la bandeja del sistema",
"launch_with_system": "Iniciar Hydra al inicio del sistema",
@ -198,7 +183,12 @@
"sync_download_sources": "Sincronizar fuentes",
"removed_download_source": "Fuente de descarga eliminada",
"added_download_source": "Fuente de descarga añadida",
"download_sources_synced": "Todas las fuentes de descarga estánn actualizadas"
"download_sources_synced": "Todas las fuentes de descarga estánn actualizadas (TRANSLATE ME)",
"insert_valid_json_url": "Insert a valid JSON url (TRANSLATE ME)",
"found_download_option_zero": "No download option found (TRANSLATE ME)",
"found_download_option_one": "Found {{countFormatted}} download option (TRANSLATE ME)",
"found_download_option_other": "Found {{countFormatted}} download options (TRANSLATE ME)",
"import": "Import (TRANSLATE ME)"
},
"notifications": {
"download_complete": "Descarga completada",
@ -224,5 +214,27 @@
},
"forms": {
"toggle_password_visibility": "Cambiar visibilidad de contraseña"
},
"user_profile": {
"amount_hours": "{{amount}} hours (TRANSLATE ME)",
"amount_minutes": "{{amount}} minutes (TRANSLATE ME)",
"last_time_played": "Last played {{period}} (TRANSLATE ME)",
"activity": "Recent activity (TRANSLATE ME)",
"library": "Library (TRANSLATE ME)",
"total_play_time": "Total playtime: {{amount}} (TRANSLATE ME)",
"no_recent_activity_title": "Hmmm… nothing here (TRANSLATE ME)",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that! (TRANSLATE ME)",
"display_name": "Display name (TRANSLATE ME)",
"saving": "Saving (TRANSLATE ME)",
"save": "Save (TRANSLATE ME)",
"edit_profile": "Edit Profile (TRANSLATE ME)",
"saved_successfully": "Saved successfully (TRANSLATE ME)",
"try_again": "Please, try again (TRANSLATE ME)",
"sign_out_modal_title": "Are you sure? (TRANSLATE ME)",
"cancel": "Cancel (TRANSLATE ME)",
"successfully_signed_out": "Successfully signed out (TRANSLATE ME)",
"sign_out": "Sign out (TRANSLATE ME)",
"playing_for": "Playing for {{amount}} (TRANSLATE ME)",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out? (TRANSLATE ME)"
}
}

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "پیشنهادی",
"recently_added": "تازه اضافه شده",
"trending": "پرطرفدار",
"surprise_me": "سوپرایزم کن",
"no_results": "اتمام‌ای پیدا نشد"
@ -15,12 +14,7 @@
"paused": "{{title}} (متوقف شده)",
"downloading": "{{title}} ({{percentage}} - در حال دانلود…)",
"filter": "فیلتر کردن کتابخانه",
"follow_us": "دنبال کردن ما",
"home": "خانه",
"discord": "عضویت در دیسکورد ما",
"telegram": "عضویت در تلگرام ما",
"x": "دنبال کرد در ایکس",
"github": "مشارکت در گیتهاب"
"home": "خانه"
},
"header": {
"search": "جستجوی بازی‌ها",
@ -50,7 +44,6 @@
"pause": "توقف",
"cancel": "بیخیال",
"remove": "حذف",
"remove_from_list": "حذف",
"space_left_on_disk": "{{space}} فضا در دیسک باقی‌مانده",
"eta": "اتمام {{eta}}",
"downloading_metadata": "در حال دانلود متادیتاها…",
@ -58,12 +51,8 @@
"requirements": "سیستم مورد نیاز",
"minimum": "حداقل",
"recommended": "پیشنهادی",
"no_minimum_requirements": "{{title}} اطلاعات حداقل سیستم مورد نیاز را فراهم نکرده",
"no_recommended_requirements": "{{title}} اطلاعات پیشنهادی سیستم مورد نیاز را فراهم نکرده",
"release_date": "منتشر شده در {{date}}",
"publisher": "منتشر شده توسط {{publisher}}",
"copy_link_to_clipboard": "کپی لینک",
"copied_link_to_clipboard": "لینک کپی شد",
"hours": "ساعت",
"minutes": "دقیقه",
"amount_hours": "{{amount}} ساعت",
@ -83,15 +72,7 @@
"change": "تغییر",
"repacks_modal_description": "ریپک مورد نظر برای دانلود را انتخاب کنید",
"select_folder_hint": "برای تغییر پوشه‌ی پیش‌فرض به <0>Settings</0> بروید",
"download_now": "الان دانلود کن",
"installation_instructions": "دستورات نصب",
"installation_instructions_description": "قدم‌های دیگری برای نصب این بازی نیاز است",
"online_fix_instruction": "بازی‌های OnlineFix برای اکسترکت‌ شدن به پسوورد نیاز دارند. در صورت نیاز، از این پسوورد استفاده کنید:",
"dodi_installation_instruction": "زمانی که اینستالر DODI را باز کردید، دکمه‌ی <0 /> را فشار دهید تا فرایند نصب شروع شود:",
"dont_show_it_again": "دیگر نمایش نده",
"copy_to_clipboard": "کپی",
"copied_to_clipboard": "کپی شد",
"got_it": "فهمیدم"
"download_now": "الان دانلود کن"
},
"activation": {
"title": "فعال کردن هایدرا",
@ -107,17 +88,13 @@
"eta": "اتمام {{eta}}",
"paused": "متوقف شده",
"verifying": "در حال اعتبارسنجی…",
"completed_at": "پایان یافته در {{date}}",
"completed": "پایان یافته",
"download_again": "دانلود مجدد",
"cancel": "لغو",
"filter": "فیلتر بازی‌های دانلود شده",
"remove": "حذف",
"downloading_metadata": "در حال دانلود متادیتاها…",
"starting_download": "در حال آغار دانلود…",
"deleting": "در حال پاک کردن اینستالر…",
"delete": "پاک کردن",
"remove_from_list": "حذف",
"delete_modal_title": "مطمئنی؟",
"delete_modal_description": "این کار تمام فایل‌های اینستالر را از کامپیوتر شما حذف می‌کند",
"install": "نصف"
@ -128,8 +105,6 @@
"notifications": "نوتیفیکشن‌ها",
"enable_download_notifications": "زمانی که یک دانلود تمام شد",
"enable_repack_list_notifications": "زمانی که یک ریپک جدید اضافه شد",
"telemetry": "تلمتری",
"telemetry_description": "فعال کردن آمارگیری استفاده ناشناس",
"quit_app_instead_hiding": "به جای کوچک کردن، از هایدرا خارج شو",
"launch_with_system": "زمانی که سیستم روشن می‌شود، هایدرا را باز کن",
"general": "کلی",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "En vedette",
"recently_added": "Récemment ajouté",
"trending": "Tendance",
"surprise_me": "Surprenez-moi",
"no_results": "Aucun résultat trouvé"
@ -15,8 +14,7 @@
"paused": "{{title}} (En pause)",
"downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)",
"filter": "Filtrer la bibliothèque",
"home": "Page daccueil",
"follow_us": "Suivez-nous"
"home": "Page daccueil"
},
"header": {
"search": "Recherche",
@ -41,7 +39,6 @@
"pause": "Pause",
"cancel": "Annuler",
"remove": "Supprimer",
"remove_from_list": "Retirer",
"space_left_on_disk": "{{space}} restant sur le disque",
"eta": "Fin dans {{eta}}",
"downloading_metadata": "Téléchargement des métadonnées en cours…",
@ -49,12 +46,8 @@
"requirements": "Configuration requise",
"minimum": "Minimum",
"recommended": "Recommandée",
"no_minimum_requirements": "{{title}} ne fournit pas d'informations sur les configurations minimales",
"no_recommended_requirements": "{{title}} ne fournit pas d'informations sur les configurations recommandées",
"release_date": "Sorti le {{date}}",
"publisher": "Édité par {{publisher}}",
"copy_link_to_clipboard": "Copier le lien",
"copied_link_to_clipboard": "Lien copié",
"hours": "heures",
"minutes": "minutes",
"amount_hours": "{{amount}} heures",
@ -87,15 +80,11 @@
"eta": "Fin dans {{eta}}",
"paused": "En pause",
"verifying": "Vérification en cours…",
"completed_at": "Terminé en {{date}}",
"completed": "Terminé",
"download_again": "Télécharger à nouveau",
"cancel": "Annuler",
"filter": "Filtrer les jeux téléchargés",
"remove": "Supprimer",
"downloading_metadata": "Téléchargement des métadonnées en cours…",
"starting_download": "Démarrage du téléchargement…",
"remove_from_list": "Retirer",
"delete": "Supprimer le programme d'installation",
"delete_modal_description": "Cela supprimera tous les fichiers d'installation de votre ordinateur",
"delete_modal_title": "Es-tu sûr?",
@ -108,8 +97,6 @@
"notifications": "Notifications",
"enable_download_notifications": "Quand un téléchargement est terminé",
"enable_repack_list_notifications": "Quand un nouveau repack est ajouté",
"telemetry": "Télémétrie",
"telemetry_description": "Activer les statistiques d'utilisation anonymes",
"language": "Langue"
},
"notifications": {

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Featured",
"recently_added": "Nemrég hozzáadott",
"trending": "Népszerű",
"surprise_me": "Lepj meg",
"no_results": "Nem található"
@ -15,7 +14,6 @@
"paused": "{{title}} (Szünet)",
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"follow_us": "Kövess minket",
"home": "Főoldal"
},
"header": {
@ -46,7 +44,6 @@
"pause": "Szüneteltetés",
"cancel": "Mégse",
"remove": "Eltávolítás",
"remove_from_list": "Eltávolítás",
"space_left_on_disk": "{{space}} szabad hely a lemezen",
"eta": "Befejezés {{eta}}",
"downloading_metadata": "Metaadatok letöltése…",
@ -54,12 +51,8 @@
"requirements": "Rendszerkövetelmények",
"minimum": "Minimális",
"recommended": "Ajánlott",
"no_minimum_requirements": "{{title}} nem tartalmaz információt a minimális követelményekről",
"no_recommended_requirements": "{{title}} nem tartalmaz információt az ajánlott követelményekről",
"release_date": "Megjelenés: {{date}}",
"publisher": "Kiadta: {{publisher}}",
"copy_link_to_clipboard": "Link másolása",
"copied_link_to_clipboard": "Link másolva",
"hours": "óra",
"minutes": "perc",
"amount_hours": "{{amount}} óra",
@ -95,17 +88,13 @@
"eta": "Befejezés {{eta}}",
"paused": "Szüneteltetve",
"verifying": "Ellenőrzés…",
"completed_at": "Befejezve {{date}}-kor",
"completed": "Befejezve",
"download_again": "Újra letöltés",
"cancel": "Mégse",
"filter": "Letöltött játékok szűrése",
"remove": "Eltávolítás",
"downloading_metadata": "Metaadatok letöltése…",
"starting_download": "Letöltés indítása…",
"deleting": "Telepítő törlése…",
"delete": "Telepítő eltávolítása",
"remove_from_list": "Eltávolítás",
"delete_modal_title": "Biztos vagy benne?",
"delete_modal_description": "Ez eltávolít minden telepítési fájlt a számítógépedről",
"install": "Telepítés"
@ -115,9 +104,7 @@
"change": "Frissítés",
"notifications": "Értesítések",
"enable_download_notifications": "Amikor egy letöltés befejeződik",
"enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül",
"telemetry": "Telemetria",
"telemetry_description": "Névtelen felhasználási statisztikák engedélyezése"
"enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül"
},
"notifications": {
"download_complete": "Letöltés befejeződött",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Unggulan",
"recently_added": "Terbaru",
"trending": "Trending",
"surprise_me": "Kejutkan Saya",
"no_results": "Tidak ada hasil"
@ -15,12 +14,7 @@
"paused": "{{title}} (Terhenti)",
"downloading": "{{title}} ({{percentage}} - Mengunduh…)",
"filter": "Filter koleksi",
"follow_us": "Ikuti kami",
"home": "Beranda",
"discord": "Gabung Discord kami",
"telegram": "Gabung Telegram kami",
"x": "Ikuti akun X kami",
"github": "Kontribusi di GitHub"
"home": "Beranda"
},
"header": {
"search": "Pencarian",
@ -50,7 +44,6 @@
"pause": "Hentikan sementara",
"cancel": "Batalkan",
"remove": "Hapus",
"remove_from_list": "Hapus",
"space_left_on_disk": "{{space}} tersisa pada disk",
"eta": "Perkiraan {{eta}}",
"downloading_metadata": "Mengunduh metadata…",
@ -58,12 +51,8 @@
"requirements": "Keperluan sistem",
"minimum": "Minimum",
"recommended": "Rekomendasi",
"no_minimum_requirements": "{{title}} Tidak ada informasi kebutuhan sistem",
"no_recommended_requirements": "{{title}} Tidak ada informasi rekomendasi kebutuhan sistem",
"release_date": "Dirilis pada {{date}}",
"publisher": "Dipublikasikan oleh {{publisher}}",
"copy_link_to_clipboard": "Salin tautan",
"copied_link_to_clipboard": "Tautan tersalin",
"hours": "jam",
"minutes": "menit",
"amount_hours": "{{amount}} jam",
@ -83,15 +72,7 @@
"change": "Ubah",
"repacks_modal_description": "Pilih repack yang kamu ingin unduh",
"select_folder_hint": "Untuk merubah folder bawaan, akses melalui",
"download_now": "Unduh sekarang",
"installation_instructions": "Instruksi Instalasi",
"installation_instructions_description": "Langkah tambahan dibutuhkan untuk meng-instal game ini",
"online_fix_instruction": "OnlineFix games mebutuhkan kata sandi untuk ekstraksi. Saat diperlukan, gunakan kata sandi ini:",
"dodi_installation_instruction": "Saat menjalankan DODI installer, tekan tombol atas pada keyboard <0 /> untuk melanjutkan proses instalasi:",
"dont_show_it_again": "Jangan tunjukkan lagi",
"copy_to_clipboard": "Salin",
"copied_to_clipboard": "Tersalin",
"got_it": "Paham"
"download_now": "Unduh sekarang"
},
"activation": {
"title": "Aktivasi Hydra",
@ -107,17 +88,13 @@
"eta": "Perkiraan {{eta}}",
"paused": "Terhenti sementara",
"verifying": "Memeriksa…",
"completed_at": "Selesai pada {{date}}",
"completed": "Selesai",
"download_again": "Unduh lagi",
"cancel": "Batalkan",
"filter": "Saring game yang diunduh",
"remove": "Hapus",
"downloading_metadata": "Mengunduh metadata…",
"starting_download": "Memulai unduhan…",
"deleting": "Menghapus file instalasi…",
"delete": "Hapus file instalasi",
"remove_from_list": "Hapus",
"delete_modal_title": "Kamu yakin?",
"delete_modal_description": "Proses ini akan menghapus semua file instalasi dari komputer kamu",
"install": "Install"
@ -128,8 +105,6 @@
"notifications": "Pengingat",
"enable_download_notifications": "Saat unduhan selesai",
"enable_repack_list_notifications": "Saat repack terbaru ditambahkan",
"telemetry": "Telemetri",
"telemetry_description": "Izinkan statistik penggunaan data anonim",
"behavior": "Perilaku",
"quit_app_instead_hiding": "Tutup aplikasi alih-alih menyembunyikan aplikasi",
"launch_with_system": "Jalankan saat memulai sistem"

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "In primo piano",
"recently_added": "Aggiunti di recente",
"trending": "Di tendenza",
"surprise_me": "Sorprendimi",
"no_results": "Nessun risultato trovato"
@ -15,12 +14,7 @@
"paused": "{{title}} (In pausa)",
"downloading": "{{title}} ({{percentage}} - Download…)",
"filter": "Filtra libreria",
"follow_us": "Seguici",
"home": "Home",
"discord": "Unisciti al nostro Discord",
"telegram": "Unisciti al nostro Telegram",
"x": "Segui su X",
"github": "Contribuisci su GitHub"
"home": "Home"
},
"header": {
"search": "Cerca",
@ -50,7 +44,6 @@
"pause": "Metti in pausa",
"cancel": "Annulla",
"remove": "Rimuovi",
"remove_from_list": "Rimuovi",
"space_left_on_disk": "{{space}} rimasto sul disco",
"eta": "Conclusione {{eta}}",
"downloading_metadata": "Scaricamento metadati…",
@ -58,12 +51,8 @@
"requirements": "Requisiti di sistema",
"minimum": "Minimi",
"recommended": "Consigliati",
"no_minimum_requirements": "{{title}} non fornisce informazioni sui requisiti minimi",
"no_recommended_requirements": "{{title}} non fornisce informazioni sui requisiti consigliati",
"release_date": "Rilasciato il {{date}}",
"publisher": "Pubblicato da {{publisher}}",
"copy_link_to_clipboard": "Copia link",
"copied_link_to_clipboard": "Link copiato",
"hours": "ore",
"minutes": "minuti",
"amount_hours": "{{amount}} ore",
@ -84,14 +73,6 @@
"repacks_modal_description": "Scegli il repack che vuoi scaricare",
"select_folder_hint": "Per cambiare la cartella predefinita, accedi alle",
"download_now": "Scarica ora",
"installation_instructions": "Istruzioni di installazione",
"installation_instructions_description": "Sono necessari passaggi aggiuntivi per installare questo gioco",
"online_fix_instruction": "I giochi OnlineFix richiedono una password per essere estratti. Quando richiesto, utilizza la seguente password:",
"dodi_installation_instruction": "Quando apri l'installatore di DODI, premi il tasto su della tua tastiera <0 /> per avviare il processo di installazione:",
"dont_show_it_again": "Non mostrarlo più",
"copy_to_clipboard": "Copia",
"copied_to_clipboard": "Copiato",
"got_it": "Capito",
"no_shop_details": "Impossibile recuperare i dettagli del negozio.",
"download_options": "Opzioni di download",
"download_path": "Percorso di download",
@ -114,17 +95,13 @@
"eta": "Conclusione {{eta}}",
"paused": "In pausa",
"verifying": "Verifica…",
"completed_at": "Completato in {{date}}",
"completed": "Completato",
"download_again": "Scarica di nuovo",
"cancel": "Annulla",
"filter": "Filtra giochi scaricati",
"remove": "Rimuovi",
"downloading_metadata": "Scaricamento metadati…",
"starting_download": "Avvio download…",
"deleting": "Eliminazione dell'installer…",
"delete": "Rimuovi installer",
"remove_from_list": "Rimuovi",
"delete_modal_title": "Sei sicuro?",
"delete_modal_description": "Questo rimuoverà tutti i file di installazione dal tuo computer",
"install": "Installa"
@ -135,8 +112,6 @@
"notifications": "Notifiche",
"enable_download_notifications": "Quando un download è completo",
"enable_repack_list_notifications": "Quando viene aggiunto un nuovo repack",
"telemetry": "Telemetria",
"telemetry_description": "Abilita statistiche di utilizzo anonime",
"real_debrid_api_token_label": "Token API Real Debrid",
"quit_app_instead_hiding": "Esci da Hydra invece di nascondere nell'area di notifica",
"launch_with_system": "Apri Hydra all'avvio",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "추천",
"recently_added": "최근 추가됨",
"trending": "인기",
"surprise_me": "무작위 추천",
"no_results": "결과 없음"
@ -15,12 +14,7 @@
"paused": "{{title}} (일시 정지됨)",
"downloading": "{{title}} ({{percentage}} - 다운로드 중…)",
"filter": "라이브러리 정렬",
"follow_us": "공식 SNS",
"home": "홈",
"discord": "공식 디스코드",
"telegram": "공식 텔레그램",
"x": "공식 X (구 트위터)",
"github": "GitHub에서 기여하기"
"home": "홈"
},
"header": {
"search": "게임 검색하기",
@ -50,7 +44,6 @@
"pause": "일시 정지",
"cancel": "취소",
"remove": "제거",
"remove_from_list": "목록에서 제거",
"space_left_on_disk": "여유 저장 용량 {{space}} 남음",
"eta": "완료까지 {{eta}}",
"downloading_metadata": "메타데이터 다운로드 중…",
@ -58,12 +51,8 @@
"requirements": "시스템 사양",
"minimum": "최저 사양",
"recommended": "권장 사양",
"no_minimum_requirements": "{{title}}의 최저 사양을 제공받지 못 함",
"no_recommended_requirements": "{{title}}의 권장 사양을 제공받지 못 함",
"release_date": "{{date}}에 발매됨",
"publisher": "{{publisher}} 배급",
"copy_link_to_clipboard": "링크 복사하기",
"copied_link_to_clipboard": "링크 복사됨",
"hours": "시",
"minutes": "분",
"amount_hours": "{{amount}} 시간",
@ -83,15 +72,7 @@
"change": "바꾸기",
"repacks_modal_description": "다운로드 할 리팩을 선택해 주세요",
"select_folder_hint": "기본 폴더를 바꾸려면 <0>설정</0>으로 가세요",
"download_now": "지금 다운로드",
"installation_instructions": "설치 방법",
"installation_instructions_description": "이 게임을 설치하기 위해서는 추가적인 단계가 필요합니다",
"online_fix_instruction": "OnlineFix 게임들은 압축 해제 시 암호가 필요합니다. 비밀번호를 물을 때 다음을 암호로 사용하기:",
"dodi_installation_instruction": "DODI 인스톨러를 실행했다면 키보드의 위 방향키를 눌러 설치를 시작하세요:",
"dont_show_it_again": "다시 보지 않기",
"copy_to_clipboard": "복사하기",
"copied_to_clipboard": "복사됨",
"got_it": "알았습니다"
"download_now": "지금 다운로드"
},
"activation": {
"title": "Hydra 실행",
@ -107,17 +88,13 @@
"eta": "완료까지 {{eta}}",
"paused": "일시 정지됨",
"verifying": "검증중…",
"completed_at": "{{date}}에 완료됨",
"completed": "완료됨",
"download_again": "다시 다운로드 하기",
"cancel": "취소",
"filter": "다운로드 된 게임들을 정렬하기",
"remove": "제거하기",
"downloading_metadata": "메타데이터 다운로드 중…",
"starting_download": "다운로드 개시 중…",
"deleting": "인스톨러 삭제 중…",
"delete": "인스톨러 삭제하기",
"remove_from_list": "제거하기",
"delete_modal_title": "정말로 하시겠습니까?",
"delete_modal_description": "이 기기의 모든 설치 파일들이 제거될 것입니다",
"install": "설치"
@ -128,8 +105,6 @@
"notifications": "알림",
"enable_download_notifications": "다운로드가 완료되었을 때",
"enable_repack_list_notifications": "새 리팩이 추가되었을 때",
"telemetry": "자동 데이터 수집",
"telemetry_description": "익명 사용 통계를 활성화",
"quit_app_instead_hiding": "작업 표시줄 트레이로 최소화하는 대신 Hydra를 종료",
"launch_with_system": "컴퓨터가 시작되었을 때 Hydra 실행",
"general": "일반",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Uitgelicht",
"recently_added": "Recent Toegevoegd",
"trending": "Trending",
"surprise_me": "Verrasing",
"no_results": "Geen resultaten gevonden"
@ -15,12 +14,7 @@
"paused": "{{title}} (Gepauzeerd)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter Bibliotheek",
"follow_us": "volg ons",
"home": "Home",
"discord": "Volg onze Discord",
"telegram": "Volg onze Telegram",
"x": "Volg ons op X",
"github": "Contribute op GitHub"
"home": "Home"
},
"header": {
"search": "Zoek spellen",
@ -50,7 +44,6 @@
"pause": "Pauze",
"cancel": "Stoppen",
"remove": "Verwijderen",
"remove_from_list": "Verwijdere van lijst",
"space_left_on_disk": "{{space}} Over op schijf",
"eta": "Conclusie {{eta}}",
"downloading_metadata": "Downloading metadata…",
@ -58,12 +51,8 @@
"requirements": "Systeem vereisten",
"minimum": "Minimaal",
"recommended": "Aanbevolen",
"no_minimum_requirements": "{{title}} biedt geen informatie over de minimale vereisten",
"no_recommended_requirements": "{{title}} biedt geen informatie over aanbevolen vereisten",
"release_date": "Uitgebracht op {{date}}",
"publisher": "Gepubliceerd door {{publisher}}",
"copy_link_to_clipboard": "Kopieer link",
"copied_link_to_clipboard": "Link Gekopieerd",
"hours": "uren",
"minutes": "minuten",
"amount_hours": "{{amount}} uren",
@ -83,15 +72,7 @@
"change": "Verander",
"repacks_modal_description": "Kies de herverpakking die u wilt downloaden",
"select_folder_hint": "Om de standaardmap te wijzigen, gaat u naar <0>instellingen</0>",
"download_now": "Download nu",
"installation_instructions": "Installatie instructies",
"installation_instructions_description": "Er zijn extra stappen vereist om deze game te installeren",
"online_fix_instruction": "OnlineFix-spellen vereisen dat een wachtwoord wordt uitgepakt. Gebruik indien nodig het volgende wachtwoord:",
"dodi_installation_instruction": "Wanneer u het DODI-installatieprogramma opent, drukt u op de toets omhoog <0 /> op uw toetsenbord om het installatieproces te starten:",
"dont_show_it_again": "Laat het niet meer zien",
"copy_to_clipboard": "Kopiëren",
"copied_to_clipboard": "Gekopieerd",
"got_it": "Begrepen"
"download_now": "Download nu"
},
"activation": {
"title": "Activeer Hydra",
@ -107,17 +88,13 @@
"eta": "Conclusie{{eta}}",
"paused": "Gepauzeerd",
"verifying": "Verifiëren…",
"completed_at": "Voltooid binnen {{date}}",
"completed": "Voltooid",
"download_again": "Opnieuw downloaden",
"cancel": "Annuleren",
"filter": "Filter gedownloade games",
"remove": "Verwijderen",
"downloading_metadata": "Metagegevens downloaden",
"starting_download": "download starten",
"deleting": "Installatieprogramma verwijderen…",
"delete": "Installatieprogramma verwijderen",
"remove_from_list": "Verwijderen",
"delete_modal_title": "Weet je het zeker?",
"delete_modal_description": "Hiermee worden alle installatiebestanden van uw computer verwijderd",
"install": "Installeren"
@ -128,8 +105,6 @@
"notifications": "Meldingen",
"enable_download_notifications": "Wanneer een download voltooid is",
"enable_repack_list_notifications": "Wanneer een nieuwe herverpakking wordt toegevoegd",
"telemetry": "Telemetrie",
"telemetry_description": "Schakel anonieme gebruiksstatistieken in",
"real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "Sluit Hydra af in plaats van te minimaliseren naar de lade",
"launch_with_system": "Start Hydra bij het opstarten van het systeem",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Wyróżnione",
"recently_added": "Ostatnio dodane",
"trending": "Trendujące",
"surprise_me": "Zaskocz mnie",
"no_results": "Nie znaleziono wyników"
@ -15,12 +14,7 @@
"paused": "{{title}} (Zatrzymano)",
"downloading": "{{title}} ({{percentage}} - Pobieranie…)",
"filter": "Filtruj biblioteke",
"follow_us": "Śledź nas",
"home": "Główna",
"discord": "Dołącz nasz Discord",
"telegram": "Dołącz nasz Telegram",
"x": "Śledź na X",
"github": "Przyczyń się na GitHub"
"home": "Główna"
},
"header": {
"search": "Szukaj",
@ -50,7 +44,6 @@
"pause": "Zatrzymaj",
"cancel": "Anuluj",
"remove": "Usuń",
"remove_from_list": "Usuń",
"space_left_on_disk": "{{space}} wolnego na dysku",
"eta": "Podsumowanie {{eta}}",
"downloading_metadata": "Pobieranie metadata…",
@ -58,12 +51,8 @@
"requirements": "Wymagania systemowe",
"minimum": "Minimalne",
"recommended": "Zalecane",
"no_minimum_requirements": "{{title}} nie zawiera informacji o minimalnych wymaganiach",
"no_recommended_requirements": "{{title}} nie zawiera informacji o zalecanych wymaganiach",
"release_date": "Wydano w {{date}}",
"publisher": "Opublikowany przez {{publisher}}",
"copy_link_to_clipboard": "Kopiuj łącze",
"copied_link_to_clipboard": "Skopiowano łącze",
"hours": "godzin",
"minutes": "minut",
"amount_hours": "{{amount}} godzin",
@ -84,14 +73,6 @@
"repacks_modal_description": "Wybierz repack, który chcesz pobrać",
"select_folder_hint": "Aby zmienić domyślny folder, przejdź do",
"download_now": "Pobierz teraz",
"installation_instructions": "Instrukcja instalacji",
"installation_instructions_description": "Do zainstalowania tej gry wymagane są dodatkowe kroki",
"online_fix_instruction": "Gry OnlineFix wymagają hasła do wyodrębnienia. W razie potrzeby użyj następującego hasła:",
"dodi_installation_instruction": "Po otwarciu instalatora DODI naciśnij klawisz <0 /> w górę, aby rozpocząć proces instalacji:",
"dont_show_it_again": "Nie pokazuj tego ponownie",
"copy_to_clipboard": "Skopiuj",
"copied_to_clipboard": "Skopiowano",
"got_it": "Rozumiem",
"no_shop_details": "Nie udało się pobrać danych sklepu.",
"download_options": "Opcje pobierania",
"download_path": "Ścieżka pobierania",
@ -114,17 +95,13 @@
"eta": "Podsumowanie {{eta}}",
"paused": "Zatrzymano",
"verifying": "Weryfikowanie…",
"completed_at": "Zakończono w {{date}}",
"completed": "Zakończono",
"download_again": "Pobierz ponownie",
"cancel": "Anuluj",
"filter": "Filtruj pobrane gry",
"remove": "Usuń",
"downloading_metadata": "Pobieranie metadata…",
"starting_download": "Rozpoczęto pobieranie…",
"deleting": "Usuwanie instalatora…",
"delete": "Usuń instalator",
"remove_from_list": "Usuń",
"delete_modal_title": "Czy na pewno?",
"delete_modal_description": "Spowoduje to usunięcie wszystkich plików instalacyjnych z komputera",
"install": "Instaluj"
@ -135,8 +112,6 @@
"notifications": "Powiadomienia",
"enable_download_notifications": "Gdy pobieranie zostanie zakończone",
"enable_repack_list_notifications": "Gdy dodawany jest nowy repack",
"telemetry": "Telemetria",
"telemetry_description": "Włącz anonimowe statystyki użycia",
"real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "Zamknij Hydr zamiast minimalizować do zasobnika",
"launch_with_system": "Uruchom Hydra przy starcie systemu",

View File

@ -1,4 +1,7 @@
{
"app": {
"successfully_signed_in": "Logado com sucesso"
},
"home": {
"featured": "Destaque",
"trending": "Populares",
@ -15,9 +18,9 @@
"downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca",
"home": "Início",
"follow_us": "Acompanhe-nos",
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado"
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login"
},
"header": {
"search": "Buscar jogos",
@ -45,7 +48,6 @@
"pause": "Pausar",
"cancel": "Cancelar",
"remove": "Remover",
"remove_from_list": "Remover",
"space_left_on_disk": "{{space}} livres em disco",
"eta": "Conclusão {{eta}}",
"calculating_eta": "Calculando tempo restante…",
@ -54,13 +56,9 @@
"requirements": "Requisitos do sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
"no_minimum_requirements": "{{title}} não possui informações de requisitos mínimos",
"no_recommended_requirements": "{{title}} não possui informações de requisitos recomendados",
"paused": "Pausado",
"release_date": "Lançado em {{date}}",
"publisher": "Publicado por {{publisher}}",
"copy_link_to_clipboard": "Copiar link",
"copied_link_to_clipboard": "Link copiado",
"hours": "horas",
"minutes": "minutos",
"amount_hours": "{{amount}} horas",
@ -82,14 +80,6 @@
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download",
"installation_instructions": "Instruções de Instalação",
"installation_instructions_description": "Passos adicionais são necessários para instalar esse jogo",
"online_fix_instruction": "Jogos OnlineFix precisam de uma senha para serem extraídos. Quando solicitado, utilize a seguinte senha:",
"dodi_installation_instruction": "Quando o instalador do DODI for aberto, pressione a seta para cima <0 /> do teclado para iniciar o processo de instalação:",
"dont_show_it_again": "Não mostrar novamente",
"copy_to_clipboard": "Copiar",
"copied_to_clipboard": "Copiado",
"got_it": "Entendi",
"no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download",
"download_path": "Diretório de download",
@ -134,16 +124,12 @@
"eta": "Conclusão {{eta}}",
"paused": "Pausado",
"verifying": "Verificando…",
"completed_at": "Concluído em {{date}}",
"completed": "Concluído",
"removed": "Cancelado",
"download_again": "Baixar novamente",
"cancel": "Cancelar",
"filter": "Filtrar jogos baixados",
"remove": "Remover",
"downloading_metadata": "Baixando metadados…",
"starting_download": "Iniciando download…",
"remove_from_list": "Remover",
"delete": "Remover instalador",
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?",
@ -162,8 +148,6 @@
"notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"telemetry": "Telemetria",
"telemetry_description": "Habilitar estatísticas de uso anônimas",
"real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra ao invés de minimizá-lo ao fechar",
"launch_with_system": "Iniciar o Hydra junto com o sistema",
@ -197,7 +181,12 @@
"sync_download_sources": "Sincronizar",
"removed_download_source": "Fonte removida",
"added_download_source": "Fonte adicionada",
"download_sources_synced": "As fontes foram sincronizadas"
"download_sources_synced": "As fontes foram sincronizadas",
"insert_valid_json_url": "Insira a url de um JSON válido",
"found_download_option_zero": "Nenhuma opção de download encontrada",
"found_download_option_one": "{{countFormatted}} opção de download encontrada",
"found_download_option_other": "{{countFormatted}} opções de download encontradas",
"import": "Importar"
},
"notifications": {
"download_complete": "Download concluído",
@ -227,5 +216,27 @@
},
"forms": {
"toggle_password_visibility": "Alternar visibilidade da senha"
},
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"last_time_played": "Jogou {{period}}",
"activity": "Atividade recente",
"library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}",
"no_recent_activity_title": "Hmmm… nada por aqui",
"no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?",
"display_name": "Nome de exibição",
"saving": "Salvando…",
"save": "Salvar",
"edit_profile": "Editar Perfil",
"saved_successfully": "Salvo com sucesso",
"try_again": "Por favor, tente novamente",
"cancel": "Cancelar",
"successfully_signed_out": "Deslogado com sucesso",
"sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?",
"playing_for": "Jogando por {{amount}}",
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?"
}
}

View File

@ -1,4 +1,7 @@
{
"app": {
"successfully_signed_in": "Successfully signed in (TRANSLATE ME)"
},
"home": {
"featured": "Рекомендованное",
"trending": "В тренде",
@ -16,7 +19,8 @@
"filter": "Фильтр библиотеки",
"home": "Главная",
"queued": "{{title}} (В очереди)",
"game_has_no_executable": "Файл запуска игры не выбран"
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Sign in (TRANSLATE ME)"
},
"header": {
"search": "Поиск",
@ -49,7 +53,6 @@
"pause": "Приостановить",
"cancel": "Отменить",
"remove": "Удалить",
"remove_from_list": "Удалить",
"space_left_on_disk": "{{space}} свободно на диске",
"eta": "Окончание {{eta}}",
"calculating_eta": "Подсчёт оставшегося времени…",
@ -58,13 +61,9 @@
"requirements": "Системные требования",
"minimum": "Минимальные",
"recommended": "Рекомендуемые",
"no_minimum_requirements": "Для {{title}} не указаны минимальные требования",
"no_recommended_requirements": "Для {{title}} не указаны рекомендуемые требования",
"paused": "Приостановлено",
"release_date": "Выпущено {{date}}",
"publisher": "Издатель {{publisher}}",
"copy_link_to_clipboard": "Копировать ссылку",
"copied_link_to_clipboard": "Ссылка скопирована",
"hours": "часов",
"minutes": "минут",
"amount_hours": "{{amount}} часов",
@ -85,14 +84,6 @@
"repacks_modal_description": "Выберите репак для загрузки",
"select_folder_hint": "Чтобы изменить папку загрузок по умолчанию, откройте <0>Настройки</0>",
"download_now": "Загрузить сейчас",
"installation_instructions": "Инструкция по установке",
"installation_instructions_description": "Для установки этой игры требуются дополнительные шаги",
"online_fix_instruction": "В играх с OnlineFix требуется ввести пароль для извлечения. При необходимости используйте следующий пароль:",
"dodi_installation_instruction": "Когда вы откроете установщик DODI, нажмите на клавиатуре клавишу 'вверх' <0 />, чтобы начать процесс установки:",
"dont_show_it_again": "Не показывать снова",
"copy_to_clipboard": "Копировать",
"copied_to_clipboard": "Скопировано",
"got_it": "Понятно",
"no_shop_details": "Не удалось получить описание",
"download_options": "Вариантов загрузки",
"download_path": "Путь для загрузок",
@ -135,18 +126,14 @@
"eta": "Окончание {{eta}}",
"paused": "Приостановлено",
"verifying": "Проверка…",
"completed_at": "Завершено в {{date}}",
"completed": "Завершено",
"removed": "Не скачано",
"download_again": "Загрузить снова",
"cancel": "Отмена",
"filter": "Фильтр загруженных игр",
"remove": "Удалить",
"downloading_metadata": "Загрузка метаданных…",
"starting_download": "Начало загрузки…",
"deleting": "Удаление установщика…",
"delete": "Удалить установщик",
"remove_from_list": "Удалить",
"delete_modal_title": "Вы уверены?",
"delete_modal_description": "Это удалит все установщики с вашего компьютера",
"install": "Установить",
@ -163,8 +150,6 @@
"notifications": "Уведомления",
"enable_download_notifications": "По завершении загрузки",
"enable_repack_list_notifications": "При добавлении нового репака",
"telemetry": "Телеметрия",
"telemetry_description": "Отправлять анонимную статистику использования",
"real_debrid_api_token_label": "Real-Debrid API-токен",
"quit_app_instead_hiding": "Закрывать Hydra вместо того, чтобы сворачивать его в трей",
"launch_with_system": "Запуск Hydra вместе с системой",
@ -198,7 +183,12 @@
"sync_download_sources": "Синхронизировать источники",
"removed_download_source": "Источник загрузок удален",
"added_download_source": "Источник загрузок добавлен",
"download_sources_synced": "Все источники загрузок синхронизированы"
"download_sources_synced": "All download sources are synced (TRANSLATE ME)",
"insert_valid_json_url": "Insert a valid JSON url (TRANSLATE ME)",
"found_download_option_zero": "No download option found (TRANSLATE ME)",
"found_download_option_one": "Found {{countFormatted}} download option (TRANSLATE ME)",
"found_download_option_other": "Found {{countFormatted}} download options (TRANSLATE ME)",
"import": "Import (TRANSLATE ME)"
},
"notifications": {
"download_complete": "Загрузка завершена",
@ -224,5 +214,27 @@
},
"forms": {
"toggle_password_visibility": "Показывать пароль"
},
"user_profile": {
"amount_hours": "{{amount}} hours (TRANSLATE ME)",
"amount_minutes": "{{amount}} minutes (TRANSLATE ME)",
"last_time_played": "Last played {{period}} (TRANSLATE ME)",
"activity": "Recent activity (TRANSLATE ME)",
"library": "Library (TRANSLATE ME)",
"total_play_time": "Total playtime: {{amount}} (TRANSLATE ME)",
"no_recent_activity_title": "Hmmm… nothing here (TRANSLATE ME)",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that! (TRANSLATE ME)",
"display_name": "Display name (TRANSLATE ME)",
"saving": "Saving (TRANSLATE ME)",
"save": "Save (TRANSLATE ME)",
"edit_profile": "Edit Profile (TRANSLATE ME)",
"saved_successfully": "Saved successfully (TRANSLATE ME)",
"try_again": "Please, try again (TRANSLATE ME)",
"sign_out_modal_title": "Are you sure? (TRANSLATE ME)",
"cancel": "Cancel (TRANSLATE ME)",
"successfully_signed_out": "Successfully signed out (TRANSLATE ME)",
"sign_out": "Sign out (TRANSLATE ME)",
"playing_for": "Playing for {{amount}} (TRANSLATE ME)",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out? (TRANSLATE ME)"
}
}

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Öne çıkan",
"recently_added": "Son eklenen",
"trending": "Popüler",
"surprise_me": "Şaşırt beni",
"no_results": "Sonuç bulunamadı"
@ -15,12 +14,7 @@
"paused": "{{title}} (Duraklatıldı)",
"downloading": "{{title}} ({{percentage}} - İndiriliyor…)",
"filter": "Kütüphaneyi filtrele",
"follow_us": "Bizi takip et",
"home": "Ana menü",
"discord": "Discord'umuza katıl",
"telegram": "Telegram'umuza katıl",
"x": "X'te bizi takip et",
"github": "GitHub'da bize katkı yap"
"home": "Ana menü"
},
"header": {
"search": "Ara",
@ -50,7 +44,6 @@
"pause": "Duraklat",
"cancel": "İptal et",
"remove": "Sil",
"remove_from_list": "Sil",
"space_left_on_disk": "Diskte {{space}} yer kaldı",
"eta": "Bitiş {{eta}}",
"downloading_metadata": "Metadata indiriliyor…",
@ -58,12 +51,8 @@
"requirements": "Sistem gereksinimleri",
"minimum": "Minimum",
"recommended": "Önerilen",
"no_minimum_requirements": "{{title}} minimum sistem gereksinim bilgilerini karşılamıyor",
"no_recommended_requirements": "{{title}} önerilen sistem gereksinim bilgilerini karşılamıyor",
"release_date": "{{date}} tarihinde çıktı",
"publisher": "{{publisher}} tarihinde yayınlandı",
"copy_link_to_clipboard": "Link'i kopyala",
"copied_link_to_clipboard": "Link kopyalandı",
"hours": "saatler",
"minutes": "dakikalar",
"amount_hours": "{{amount}} saat",
@ -83,15 +72,7 @@
"change": "Değiştir",
"repacks_modal_description": "İndirmek istediğiiniz repacki seçin",
"select_folder_hint": "Varsayılan klasörü değiştirmek için ulaşmanız gereken ayar",
"download_now": "Şimdi",
"installation_instructions": "Kurulum",
"installation_instructions_description": "Bu oyunu kurmak için ek adımlar gerekiyor",
"online_fix_instruction": "OnlineFix oyunlarını ayıklamak için parola gerekiyor. Gerekli olduğunda bu parolayı kullanın:",
"dodi_installation_instruction": "Dodi installerını açtığınızda, kurulumu başlatmak için bu tuşa basın <0 />:",
"dont_show_it_again": "Tekrar gösterme",
"copy_to_clipboard": "Kopyala",
"copied_to_clipboard": "Kopyalandı",
"got_it": "Tamam"
"download_now": "Şimdi"
},
"activation": {
"title": "Hydra'yı aktif et",
@ -107,17 +88,13 @@
"eta": "Bitiş {{eta}}",
"paused": "Duraklatıldı",
"verifying": "Doğrulanıyor…",
"completed_at": "{{date}} tarihinde tamamlanacak",
"completed": "Tamamlandı",
"download_again": "Tekrar indir",
"cancel": "İptal et",
"filter": "Yüklü oyunları filtrele",
"remove": "Kaldır",
"downloading_metadata": "Metadata indiriliyor…",
"starting_download": "İndirme başlatılıyor…",
"deleting": "Installer siliniyor…",
"delete": "Installer'ı sil",
"remove_from_list": "Kaldır",
"delete_modal_title": "Emin misiniz?",
"delete_modal_description": "Bu bilgisayarınızdan tüm kurulum dosyalarını silecek",
"install": "Kur"
@ -127,9 +104,7 @@
"change": "Güncelle",
"notifications": "Bildirimler",
"enable_download_notifications": "Bir indirme bittiğinde",
"enable_repack_list_notifications": "Yeni bir repack eklendiğinde",
"telemetry": "Telemetri",
"telemetry_description": "Anonim kullanım istatistiklerini aktifleştir"
"enable_repack_list_notifications": "Yeni bir repack eklendiğinde"
},
"notifications": {
"download_complete": "İndirme tamamlandı",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Рекомендоване",
"recently_added": "Нове",
"trending": "У тренді",
"surprise_me": "Здивуй мене",
"no_results": "Результатів не знайдено"
@ -15,12 +14,7 @@
"paused": "{{title}} (Призупинено)",
"downloading": "{{title}} ({{percentage}} - Завантаження…)",
"filter": "Фільтр бібліотеки",
"follow_us": "Підписуйтесь на нас",
"home": "Головна",
"discord": "Приєднуйтесь до Discord",
"telegram": "Приєднуйтесь до Telegram",
"x": "Підписуйтесь на X",
"github": "Зробіть свій внесок на GitHub"
"home": "Головна"
},
"header": {
"search": "Пошук",
@ -50,7 +44,6 @@
"pause": "Призупинити",
"cancel": "Скасувати",
"remove": "Видалити",
"remove_from_list": "Видалити",
"space_left_on_disk": "{{space}} вільно на диску",
"eta": "Закінчення {{eta}}",
"downloading_metadata": "Завантаження метаданих…",
@ -58,12 +51,8 @@
"requirements": "Системні вимоги",
"minimum": "Мінімальні",
"recommended": "Рекомендовані",
"no_minimum_requirements": "Для {{title}} не вказані мінімальні вимоги",
"no_recommended_requirements": "Для {{title}} не вказані рекомендовані вимоги",
"release_date": "Випущено {{date}}",
"publisher": "Видавець {{publisher}}",
"copy_link_to_clipboard": "Скопіювати посилання",
"copied_link_to_clipboard": "Посилання скопійовано",
"hours": "годин",
"minutes": "хвилин",
"amount_hours": "{{amount}} годин",
@ -83,15 +72,7 @@
"change": "Змінити",
"repacks_modal_description": "Виберіть репак, який хочете завантажити",
"select_folder_hint": "Щоб змінити теку за замовчуванням, відкрийте",
"download_now": "Завантажити зараз",
"installation_instructions": "Інструкція зі встановлення",
"installation_instructions_description": "Для встановлення цієї гри потрібні додаткові кроки",
"online_fix_instruction": "В іграх з OnlineFix потрібно ввести пароль для вилучення. За необхідності використовуйте наступний пароль:",
"dodi_installation_instruction": "Коли ви відкриєте інсталятор DODI, натисніть на клавіатурі клавішу 'вгору' <0 />, щоб почати процес встановлення:",
"dont_show_it_again": "Не показувати це знову",
"copy_to_clipboard": "Копіювати",
"copied_to_clipboard": "Скопійовано",
"got_it": "Зрозуміло"
"download_now": "Завантажити зараз"
},
"activation": {
"title": "Активувати Hydra",
@ -107,17 +88,13 @@
"eta": "Закінчення {{eta}}",
"paused": "Призупинено",
"verifying": "Перевірка…",
"completed_at": "Завершено в {{date}}",
"completed": "Завершено",
"download_again": "Завантажити знову",
"cancel": "Скасувати",
"filter": "Фільтр завантажених ігор",
"remove": "Видалити",
"downloading_metadata": "Завантаження метаданих…",
"starting_download": "Початок завантаження…",
"deleting": "Видалення інсталятора…",
"delete": "Видалити інсталятор",
"remove_from_list": "Видалити",
"delete_modal_title": "Ви впевнені?",
"delete_modal_description": "Це видалить усі інсталяційні файли з вашого комп'ютера",
"install": "Встановити"
@ -128,8 +105,6 @@
"notifications": "Повідомлення",
"enable_download_notifications": "Після завершення завантаження",
"enable_repack_list_notifications": "Коли додається новий репак",
"telemetry": "Телеметрія",
"telemetry_description": "Відправляти анонімну статистику використання",
"behavior": "Поведінка",
"quit_app_instead_hiding": "Закривати програму замість того, щоб згортати її в трей",
"launch_with_system": "Запускати програми із запуском комп'ютера"

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "特色推荐",
"recently_added": "最近添加",
"trending": "最近热门",
"surprise_me": "向我推荐",
"no_results": "没有找到结果"
@ -15,12 +14,7 @@
"paused": "{{title}} (已暂停)",
"downloading": "{{title}} ({{percentage}} - 正在下载…)",
"filter": "筛选游戏库",
"follow_us": "关注我们",
"home": "主页",
"discord": "加入我们的Discord",
"telegram": "加入我们的Telegram",
"x": "在X上关注我们",
"github": "在GitHub上贡献"
"home": "主页"
},
"header": {
"search": "搜索",
@ -50,7 +44,6 @@
"pause": "暂停",
"cancel": "取消",
"remove": "移除",
"remove_from_list": "从列表中移除",
"space_left_on_disk": "磁盘剩余空间{{space}}",
"eta": "预计完成时间{{eta}}",
"downloading_metadata": "正在下载元数据…",
@ -58,12 +51,8 @@
"requirements": "配置要求",
"minimum": "最低要求",
"recommended": "推荐要求",
"no_minimum_requirements": "{{title}}没有提供最低要求信息",
"no_recommended_requirements": "{{title}}没有提供推荐要求信息",
"release_date": "发布于{{date}}",
"publisher": "发行商{{publisher}}",
"copy_link_to_clipboard": "复制链接",
"copied_link_to_clipboard": "链接已复制",
"hours": "小时",
"minutes": "分钟",
"amount_hours": "{{amount}}小时",
@ -84,13 +73,6 @@
"repacks_modal_description": "选择您想要下载的重打包",
"select_folder_hint": "要更改默认文件夹,请访问",
"download_now": "立即下载",
"installation_instructions": "安装说明",
"installation_instructions_description": "安装这个游戏需要额外的步骤",
"online_fix_instruction": "OnlineFix游戏需要密码才能解压。需要时,使用以下密码:",
"dodi_installation_instruction": "打开DODI安装程序时,按键盘上的键<0 />开始安装过程:",
"dont_show_it_again": "不再显示",
"copied_to_clipboard": "已复制到剪贴板",
"got_it": "我已知晓",
"previous_screenshot": "上一张截图",
"next_screenshot": "下一张截图",
"screenshot": "截图 {{number}}",
@ -110,17 +92,13 @@
"eta": "预计完成时间{{eta}}",
"paused": "已暂停",
"verifying": "正在验证…",
"completed_at": "完成于{{date}}",
"completed": "已完成",
"download_again": "再次下载",
"cancel": "取消",
"filter": "筛选已下载游戏",
"remove": "移除",
"downloading_metadata": "正在下载元数据…",
"starting_download": "开始下载…",
"deleting": "正在删除安装程序…",
"delete": "移除安装程序",
"remove_from_list": "移除",
"delete_modal_title": "您确定吗?",
"delete_modal_description": "这将从您的电脑上移除所有的安装文件",
"install": "安装"
@ -131,8 +109,6 @@
"notifications": "通知",
"enable_download_notifications": "下载完成时",
"enable_repack_list_notifications": "添加新重打包时",
"telemetry": "遥测",
"telemetry_description": "启用匿名使用统计",
"behavior": "行为",
"general": "常规",
"quit_app_instead_hiding": "关闭应用程序而不是最小化到托盘",

View File

@ -22,6 +22,9 @@ export class Game {
@Column("text", { unique: true })
objectID: string;
@Column("text", { unique: true, nullable: true })
remoteId: string | null;
@Column("text")
title: string;

View File

@ -4,3 +4,4 @@ export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";
export * from "./user-auth";

View File

@ -11,6 +11,15 @@ export class UserAuth {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { default: "" })
userId: string;
@Column("text", { default: "" })
displayName: string;
@Column("text", { nullable: true })
profileImageUrl: string | null;
@Column("text", { default: "" })
accessToken: string;

View File

@ -0,0 +1,14 @@
import jwt from "jsonwebtoken";
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await userAuthRepository.findOne({ where: { id: 1 } });
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
return payload.sessionId;
};
registerEvent("getSessionHash", getSessionHash);

View File

@ -0,0 +1,7 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const openAuthWindow = async (_event: Electron.IpcMainInvokeEvent) =>
WindowManager.openAuthWindow();
registerEvent("openAuthWindow", openAuthWindow);

View File

@ -0,0 +1,31 @@
import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
const databaseOperations = dataSource
.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.getRepository(DownloadQueue).delete({});
await transactionalEntityManager.getRepository(Game).delete({});
await transactionalEntityManager
.getRepository(UserAuth)
.delete({ id: 1 });
})
.then(() => {
/* Removes all games being played */
gamesPlaytime.clear();
});
/* Disconnects aria2 */
DownloadManager.disconnect();
await Promise.all([
databaseOperations,
HydraApi.post("/auth/logout").catch(),
]);
};
registerEvent("signOut", signOut);

View File

@ -22,6 +22,7 @@ import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/is-user-logged-in";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./torrenting/cancel-game-download";
@ -39,6 +40,12 @@ import "./download-sources/validate-download-source";
import "./download-sources/add-download-source";
import "./download-sources/remove-download-source";
import "./download-sources/sync-download-sources";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./profile/get-me";
import "./profile/update-profile";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -6,6 +6,7 @@ import type { GameShop } from "@types";
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -49,6 +50,21 @@ const addGameToLibrary = async (
}
});
}
const game = await gameRepository.findOne({ where: { objectID } });
createGame(game!).then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
});
};

View File

@ -2,6 +2,8 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path";
import { app } from "electron";
const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent,
@ -14,10 +16,17 @@ const createGameShortcut = async (
if (game) {
const filePath = game.executablePath;
const options = { filePath, name: game.title };
const windowVbsPath = app.isPackaged
? path.join(process.resourcesPath, "windows.vbs")
: undefined;
const options = {
filePath,
name: game.title,
};
return createDesktopShortcut({
windows: options,
windows: { ...options, VBScriptPath: windowVbsPath },
linux: options,
osx: options,
});

View File

@ -1,5 +1,6 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { HydraApi, logger } from "@main/services";
const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@ -9,6 +10,18 @@ const removeGameFromLibrary = async (
{ id: gameId },
{ isDeleted: true, executablePath: null }
);
removeRemoveGameFromLibrary(gameId).catch((err) => {
logger.error("removeRemoveGameFromLibrary", err);
});
};
const removeRemoveGameFromLibrary = async (gameId: number) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
if (game?.remoteId) {
HydraApi.delete(`/games/${game.remoteId}`);
}
};
registerEvent("removeGameFromLibrary", removeGameFromLibrary);

View File

@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const isUserLoggedIn = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.isLoggedIn();
};
registerEvent("isUserLoggedIn", isUserLoggedIn);

View File

@ -0,0 +1,32 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserProfile } from "@types";
import { userAuthRepository } from "@main/repository";
import { logger } from "@main/services";
const getMe = async (
_event: Electron.IpcMainInvokeEvent
): Promise<UserProfile | null> => {
return HydraApi.get(`/profile/me`)
.then((response) => {
const me = response.data;
userAuthRepository.upsert(
{
id: 1,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
userId: me.id,
},
["id"]
);
return me;
})
.catch((err) => {
logger.error("getMe", err);
return userAuthRepository.findOne({ where: { id: 1 } });
});
};
registerEvent("getMe", getMe);

View File

@ -0,0 +1,61 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
import { fileTypeFromFile } from "file-type";
import { UserProfile } from "@types";
const patchUserProfile = async (
displayName: string,
profileImageUrl?: string
) => {
if (profileImageUrl) {
return HydraApi.patch("/profile", {
displayName,
profileImageUrl,
});
} else {
return HydraApi.patch("/profile", {
displayName,
});
}
};
const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
displayName: string,
newProfileImagePath: string | null
): Promise<UserProfile> => {
if (!newProfileImagePath) {
return (await patchUserProfile(displayName)).data;
}
const stats = fs.statSync(newProfileImagePath);
const fileBuffer = fs.readFileSync(newProfileImagePath);
const fileSizeInBytes = stats.size;
const profileImageUrl = await HydraApi.post(`/presigned-urls/profile-image`, {
imageExt: path.extname(newProfileImagePath).slice(1),
imageLength: fileSizeInBytes,
})
.then(async (preSignedResponse) => {
const { presignedUrl, profileImageUrl } = preSignedResponse.data;
const mimeType = await fileTypeFromFile(newProfileImagePath);
await axios.put(presignedUrl, fileBuffer, {
headers: {
"Content-Type": mimeType?.mime,
},
});
return profileImageUrl;
})
.catch(() => {
return undefined;
});
return (await patchUserProfile(displayName, profileImageUrl)).data;
};
registerEvent("updateProfile", updateProfile);

View File

@ -12,6 +12,7 @@ import { DownloadManager } from "@main/services";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@ -94,6 +95,19 @@ const startGameDownload = async (
},
});
createGame(updatedGame!).then((response) => {
const {
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update(
{ objectID },
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
);
});
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });

View File

@ -0,0 +1,56 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import { UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers";
const getUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserProfile | null> => {
try {
const response = await HydraApi.get(`/user/${userId}`);
const profile = response.data;
const recentGames = await Promise.all(
profile.recentGames.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
})
);
const libraryGames = await Promise.all(
profile.libraryGames.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
return {
...game,
...convertSteamGameToCatalogueEntry(steamGame),
iconUrl,
};
})
);
return { ...profile, libraryGames, recentGames };
} catch (err) {
return null;
}
};
registerEvent("getUser", getUser);

View File

@ -2,6 +2,7 @@ import { app, BrowserWindow, net, protocol } from "electron";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { DownloadManager, logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
@ -50,9 +51,10 @@ if (process.defaultApp) {
app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
protocol.handle("hydra", (request) =>
net.fetch("file://" + request.url.slice("hydra://".length))
);
protocol.handle("local", (request) => {
const filePath = request.url.slice("local:".length);
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
await dataSource.initialize();
await dataSource.runMigrations();
@ -71,7 +73,7 @@ app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
app.on("second-instance", (_event, commandLine) => {
app.on("second-instance", (_event) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
@ -82,14 +84,16 @@ app.on("second-instance", (_event, commandLine) => {
WindowManager.createMainWindow();
}
const [, path] = commandLine.pop()?.split("://") ?? [];
if (path) WindowManager.redirect(path);
// const [, path] = commandLine.pop()?.split("://") ?? [];
// if (path) {
// WindowManager.redirect(path);
// }
});
app.on("open-url", (_event, url) => {
const [, path] = url.split("://");
WindowManager.redirect(path);
});
// app.on("open-url", (_event, url) => {
// const [, path] = url.split("://");
// WindowManager.redirect(path);
// });
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits

View File

@ -10,6 +10,7 @@ import { fetchDownloadSourcesAndUpdate } from "./helpers";
import { publishNewRepacksNotifications } from "./services/notifications";
import { MoreThan } from "typeorm";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
startMainLoop();
@ -21,7 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => {
if (userPreferences?.realDebridApiToken)
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
HydraApi.setupApi();
HydraApi.setupApi().then(async () => {
if (HydraApi.isLoggedIn()) uploadGamesBatch();
});
const [nextQueueItem] = await downloadQueueRepository.find({
order: {

View File

@ -6,8 +6,8 @@ import {
GameShopCache,
Repack,
UserPreferences,
UserAuth,
} from "@main/entity";
import { UserAuth } from "./entity/user-auth";
export const gameRepository = dataSource.getRepository(Game);

View File

@ -50,6 +50,7 @@ export class DownloadManager {
public static disconnect() {
if (this.aria2c) {
this.aria2c.kill();
this.connected = false;
}
}

View File

@ -1,22 +1,100 @@
import { userAuthRepository } from "@main/repository";
import axios, { AxiosInstance } from "axios";
import axios, { AxiosError, AxiosInstance } from "axios";
import { WindowManager } from "./window-manager";
import url from "url";
import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
import { logger } from "./logger";
export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5;
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
private static userAuth = {
authToken: "",
refreshToken: "",
expirationTimestamp: 0,
};
static isLoggedIn() {
return this.userAuth.authToken !== "";
}
static async handleExternalAuth(uri: string) {
const { payload } = url.parse(uri, true).query;
const decodedBase64 = atob(payload as string);
const jsonData = JSON.parse(decodedBase64);
const { accessToken, expiresIn, refreshToken } = jsonData;
const now = new Date();
const tokenExpirationTimestamp =
now.getTime() +
this.secondsToMilliseconds(expiresIn) -
this.EXPIRATION_OFFSET_IN_MS;
this.userAuth = {
authToken: accessToken,
refreshToken: refreshToken,
expirationTimestamp: tokenExpirationTimestamp,
};
await userAuthRepository.upsert(
{
id: 1,
accessToken,
tokenExpirationTimestamp,
refreshToken,
},
["id"]
);
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds();
uploadGamesBatch();
}
}
static async setupApi() {
this.instance = axios.create({
baseURL: import.meta.env.MAIN_VITE_API_URL,
});
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.data);
return request;
},
(error) => {
logger.log("request error", error);
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
logger.log(
response.status,
response.config.method,
response.config.url,
response.data
);
return response;
},
(error) => {
logger.error("response error", error);
return Promise.reject(error);
}
);
const userAuth = await userAuthRepository.findOne({
where: { id: 1 },
});
@ -29,28 +107,59 @@ export class HydraApi {
}
private static async revalidateAccessTokenIfExpired() {
if (!this.userAuth.authToken) {
userAuthRepository.delete({ id: 1 });
logger.error("user is not logged in");
throw new Error("user is not logged in");
}
const now = new Date();
if (this.userAuth.expirationTimestamp < now.getTime()) {
const response = await this.instance.post(`/auth/refresh`, {
refreshToken: this.userAuth.refreshToken,
});
try {
const response = await this.instance.post(`/auth/refresh`, {
refreshToken: this.userAuth.refreshToken,
});
const { accessToken, expiresIn } = response.data;
const { accessToken, expiresIn } = response.data;
const tokenExpirationTimestamp =
now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS;
const tokenExpirationTimestamp =
now.getTime() +
this.secondsToMilliseconds(expiresIn) -
this.EXPIRATION_OFFSET_IN_MS;
this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
userAuthRepository.upsert(
{
id: 1,
accessToken,
tokenExpirationTimestamp,
},
["id"]
);
userAuthRepository.upsert(
{
id: 1,
accessToken,
tokenExpirationTimestamp,
},
["id"]
);
} catch (err) {
if (
err instanceof AxiosError &&
(err?.response?.status === 401 || err?.response?.status === 403)
) {
this.userAuth = {
authToken: "",
expirationTimestamp: 0,
refreshToken: "",
};
userAuthRepository.delete({ id: 1 });
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-signout");
}
logger.log("user refresh token expired");
}
throw err;
}
}
}
@ -72,13 +181,18 @@ export class HydraApi {
return this.instance.post(url, data, this.getAxiosConfig());
}
static async put(url, data?: any) {
static async put(url: string, data?: any) {
await this.revalidateAccessTokenIfExpired();
return this.instance.put(url, data, this.getAxiosConfig());
}
static async patch(url, data?: any) {
static async patch(url: string, data?: any) {
await this.revalidateAccessTokenIfExpired();
return this.instance.patch(url, data, this.getAxiosConfig());
}
static async delete(url: string) {
await this.revalidateAccessTokenIfExpired();
return this.instance.delete(url, this.getAxiosConfig());
}
}

View File

@ -8,3 +8,4 @@ export * from "./how-long-to-beat";
export * from "./process-watcher";
export * from "./main-loop";
export * from "./repacks-manager";
export * from "./hydra-api";

View File

@ -0,0 +1,5 @@
import { gameRepository } from "@main/repository";
export const clearGamesRemoteIds = () => {
return gameRepository.update({}, { remoteId: null });
};

View File

@ -0,0 +1,11 @@
import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api";
export const createGame = async (game: Game) => {
return HydraApi.post(`/games`, {
objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
});
};

View File

@ -0,0 +1,4 @@
export * from "./merge-with-remote-games";
export * from "./upload-games-batch";
export * from "./update-game-playtime";
export * from "./create-game";

View File

@ -0,0 +1,72 @@
import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers";
import { getSteamAppAsset } from "@main/helpers";
import { logger } from "../logger";
import { AxiosError } from "axios";
export const mergeWithRemoteGames = async () => {
try {
const games = await HydraApi.get("/games");
for (const game of games.data) {
const localGame = await gameRepository.findOne({
where: {
objectID: game.objectId,
},
});
if (localGame) {
const updatedLastTimePlayed =
localGame.lastTimePlayed == null ||
(game.lastTimePlayed &&
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
? game.lastTimePlayed
: localGame.lastTimePlayed;
const updatedPlayTime =
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds;
gameRepository.update(
{
objectID: game.objectId,
shop: "steam",
},
{
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
if (steamGame) {
const iconUrl = steamGame?.clientIcon
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
: null;
gameRepository.insert({
objectID: game.objectId,
title: steamGame?.name,
remoteId: game.id,
shop: game.shop,
iconUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
});
}
}
}
} catch (err) {
if (err instanceof AxiosError) {
logger.error("getRemoteGames", err.response, err.message);
} else {
logger.error("getRemoteGames", err);
}
}
};

View File

@ -0,0 +1,13 @@
import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async (
game: Game,
deltaInMillis: number,
lastTimePlayed: Date
) => {
return HydraApi.put(`/games/${game.remoteId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});
};

View File

@ -0,0 +1,44 @@
import { gameRepository } from "@main/repository";
import { chunk } from "lodash-es";
import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api";
import { logger } from "../logger";
import { AxiosError } from "axios";
import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager";
export const uploadGamesBatch = async () => {
try {
const games = await gameRepository.find({
where: { remoteId: IsNull(), isDeleted: false },
});
const gamesChunks = chunk(games, 200);
for (const chunk of gamesChunks) {
await HydraApi.post(
"/games/batch",
chunk.map((game) => {
return {
objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
};
})
);
}
await mergeWithRemoteGames();
if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
} catch (err) {
if (err instanceof AxiosError) {
logger.error("uploadGamesBatch", err.response, err.message);
} else {
logger.error("uploadGamesBatch", err);
}
}
};

View File

@ -4,8 +4,13 @@ import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { getProcesses } from "@main/helpers";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types";
const gamesPlaytime = new Map<number, number>();
export const gamesPlaytime = new Map<
number,
{ lastTick: number; firstTick: number }
>();
export const watchProcesses = async () => {
const games = await gameRepository.find({
@ -37,26 +42,67 @@ export const watchProcesses = async () => {
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
const zero = gamesPlaytime.get(game.id) ?? 0;
const delta = performance.now() - zero;
const gamePlaytime = gamesPlaytime.get(game.id)!;
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
}
const zero = gamePlaytime.lastTick;
const delta = performance.now() - zero;
await gameRepository.update(game.id, {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(),
});
}
gamesPlaytime.set(game.id, performance.now());
gamesPlaytime.set(game.id, {
...gamePlaytime,
lastTick: performance.now(),
});
} else {
if (game.remoteId) {
updateGamePlaytime(game, 0, new Date());
} else {
createGame({ ...game, lastTimePlayed: new Date() }).then(
(response) => {
const { id: remoteId } = response.data;
gameRepository.update({ objectID: game.objectID }, { remoteId });
}
);
}
gamesPlaytime.set(game.id, {
lastTick: performance.now(),
firstTick: performance.now(),
});
}
} else if (gamesPlaytime.has(game.id)) {
const gamePlaytime = gamesPlaytime.get(game.id)!;
gamesPlaytime.delete(game.id);
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
if (game.remoteId) {
updateGamePlaytime(
game,
performance.now() - gamePlaytime.firstTick,
game.lastTimePlayed!
);
} else {
createGame(game).then((response) => {
const { id: remoteId } = response.data;
gameRepository.update({ objectID: game.objectID }, { remoteId });
});
}
}
}
if (WindowManager.mainWindow) {
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
return {
id: entry[0],
sessionDurationInMillis: performance.now() - entry[1].firstTick,
};
});
WindowManager.mainWindow.webContents.send(
"on-games-running",
gamesRunning as Pick<GameRunning, "id" | "sessionDurationInMillis">[]
);
}
};

View File

@ -15,6 +15,7 @@ import icon from "@resources/icon.png?asset";
import trayIcon from "@resources/tray-icon.png?asset";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { IsNull, Not } from "typeorm";
import { HydraApi } from "./hydra-api";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
@ -80,6 +81,41 @@ export class WindowManager {
});
}
public static openAuthWindow() {
if (this.mainWindow) {
const authWindow = new BrowserWindow({
width: 600,
height: 640,
backgroundColor: "#1c1c1c",
parent: this.mainWindow,
modal: true,
show: false,
maximizable: false,
resizable: false,
minimizable: false,
webPreferences: {
sandbox: false,
},
});
authWindow.removeMenu();
authWindow.loadURL("https://auth.hydra.losbroxas.org/");
authWindow.once("ready-to-show", () => {
authWindow.show();
});
authWindow.webContents.on("will-navigate", (_event, url) => {
if (url.startsWith("hydralauncher://auth")) {
authWindow.close();
HydraApi.handleExternalAuth(url);
}
});
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadURL(hash);

View File

@ -11,6 +11,7 @@ export const steamGamesWorker = new Piscina({
workerData: {
steamGamesPath: path.join(seedsPath, "steam-games.json"),
},
maxThreads: 1,
});
export const downloadSourceWorker = new Piscina({

View File

@ -8,6 +8,7 @@ import type {
UserPreferences,
AppUpdaterEvent,
StartGameDownloadPayload,
GameRunning,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
@ -84,17 +85,21 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("deleteGameFolder", gameId),
getGameByObjectID: (objectID: string) =>
ipcRenderer.invoke("getGameByObjectID", objectID),
onPlaytime: (cb: (gameId: number) => void) => {
const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
cb(gameId);
ipcRenderer.on("on-playtime", listener);
return () => ipcRenderer.removeListener("on-playtime", listener);
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
) => void
) => {
const listener = (_event: Electron.IpcRendererEvent, gamesRunning) =>
cb(gamesRunning);
ipcRenderer.on("on-games-running", listener);
return () => ipcRenderer.removeListener("on-games-running", listener);
},
onGameClose: (cb: (gameId: number) => void) => {
const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
cb(gameId);
ipcRenderer.on("on-game-close", listener);
return () => ipcRenderer.removeListener("on-game-close", listener);
onLibraryBatchComplete: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-library-batch-complete", listener);
return () =>
ipcRenderer.removeListener("on-library-batch-complete", listener);
},
/* Hardware */
@ -106,6 +111,7 @@ contextBridge.exposeInMainWorld("electron", {
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform,
@ -125,4 +131,27 @@ contextBridge.exposeInMainWorld("electron", {
},
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"),
/* Profile */
getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
/* Auth */
signOut: () => ipcRenderer.invoke("signOut"),
openAuthWindow: () => ipcRenderer.invoke("openAuthWindow"),
getSessionHash: () => ipcRenderer.invoke("getSessionHash"),
onSignIn: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-signin", listener);
return () => ipcRenderer.removeListener("on-signin", listener);
},
onSignOut: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-signout", listener);
return () => ipcRenderer.removeListener("on-signout", listener);
},
});

View File

@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/>
</head>
<body style="background-color: #1c1c1c">

View File

@ -111,6 +111,6 @@ export const titleBar = style({
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "2",
zIndex: "4",
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);

View File

@ -7,6 +7,8 @@ import {
useAppSelector,
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import * as styles from "./app.css";
@ -18,7 +20,11 @@ import {
setUserPreferences,
toggleDraggingDisabled,
closeToast,
setUserDetails,
setProfileBackground,
setGameRunning,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
export interface AppProps {
children: React.ReactNode;
@ -26,21 +32,30 @@ export interface AppProps {
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
const { updateLibrary, library } = useLibrary();
const { t } = useTranslation("app");
const { clearDownload, setLastPacket } = useDownload();
const { fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const search = useAppSelector((state) => state.search.value);
const draggingDisabled = useAppSelector(
(state) => state.window.draggingDisabled
);
const toast = useAppSelector((state) => state.toast);
const { showSuccessToast } = useToast();
useEffect(() => {
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
([preferences]) => {
@ -67,6 +82,75 @@ export function App() {
};
}, [clearDownload, setLastPacket, updateLibrary]);
useEffect(() => {
const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) {
const { profileBackground, ...userDetails } =
JSON.parse(cachedUserDetails);
dispatch(setUserDetails(userDetails));
dispatch(setProfileBackground(profileBackground));
}
window.electron.isUserLoggedIn().then((isLoggedIn) => {
if (isLoggedIn) {
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
});
}
});
}, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
showSuccessToast(t("successfully_signed_in"));
}
});
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]);
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {
if (gamesRunning.length) {
const lastGame = gamesRunning[gamesRunning.length - 1];
const libraryGame = library.find(
(library) => library.id === lastGame.id
);
if (libraryGame) {
dispatch(
setGameRunning({
...libraryGame,
sessionDurationInMillis: lastGame.sessionDurationInMillis,
})
);
return;
}
}
dispatch(setGameRunning(null));
});
return () => {
unsubscribe();
};
}, [dispatch, library]);
useEffect(() => {
const listeners = [
window.electron.onSignIn(onSignIn),
window.electron.onLibraryBatchComplete(() => {
updateLibrary();
}),
window.electron.onSignOut(() => clearUserDetails()),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [onSignIn, updateLibrary, clearUserDetails]);
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
@ -119,6 +203,13 @@ export function App() {
</div>
)}
<Toast
visible={toast.visible}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
<main>
<Sidebar />
@ -136,13 +227,6 @@ export function App() {
</main>
<BottomPanel />
<Toast
visible={toast.visible}
message={toast.message}
type={toast.type}
onClose={handleToastClose}
/>
</>
);
}

View File

@ -1,7 +1,7 @@
import { keyframes } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const backdropFadeIn = keyframes({
"0%": { backdropFilter: "blur(0px)", backgroundColor: "rgba(0, 0, 0, 0.5)" },
@ -30,8 +30,8 @@ export const backdrop = recipe({
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1,
top: 0,
zIndex: vars.zIndex.backdrop,
top: "0",
padding: `${SPACING_UNIT * 3}px`,
backdropFilter: "blur(2px)",
transition: "all ease 0.2s",

View File

@ -12,7 +12,7 @@ export const bottomPanel = style({
transition: "all ease 0.2s",
justifyContent: "space-between",
position: "relative",
zIndex: "1",
zIndex: vars.zIndex.bottomPanel,
});
export const downloadsButton = style({

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import { useDownload, useUserDetails } from "@renderer/hooks";
import * as styles from "./bottom-panel.css";
@ -13,16 +13,23 @@ export function BottomPanel() {
const navigate = useNavigate();
const { userDetails } = useUserDetails();
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading = !!lastPacket?.game;
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
useEffect(() => {
window.electron.getVersion().then((result) => setVersion(result));
}, []);
useEffect(() => {
window.electron.getSessionHash().then((result) => setSessionHash(result));
}, [userDetails?.id]);
const status = useMemo(() => {
if (isGameDownloading) {
if (lastPacket?.isDownloadingMetadata)
@ -65,7 +72,8 @@ export function BottomPanel() {
</button>
<small>
v{version} &quot;{VERSION_CODENAME}&quot;
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
</footer>
);

View File

@ -39,6 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/user")) return headerTitle;
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);

View File

@ -9,8 +9,8 @@ import {
} from "@renderer/helpers";
import { useTranslation } from "react-i18next";
const FEATURED_GAME_TITLE = "Horizon Forbidden West™ Complete Edition";
const FEATURED_GAME_ID = "2420110";
const FEATURED_GAME_TITLE = "Ghost of Tsushima DIRECTOR'S CUT";
const FEATURED_GAME_ID = "2215430";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] =

View File

@ -0,0 +1,66 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
height: "40px",
width: "100%",
});
export const profileAvatar = style({
width: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
position: "relative",
objectFit: "cover",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});
export const profileButtonTitle = style({
fontWeight: "bold",
fontSize: vars.size.body,
width: "100%",
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});

View File

@ -0,0 +1,75 @@
import { useNavigate } from "react-router-dom";
import { PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export function SidebarProfile() {
const navigate = useNavigate();
const { t } = useTranslation("sidebar");
const { userDetails, profileBackground } = useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const handleButtonClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow();
return;
}
navigate(`/user/${userDetails!.id}`);
};
const profileButtonBackground = useMemo(() => {
if (profileBackground) return profileBackground;
return undefined;
}, [profileBackground]);
return (
<button
type="button"
className={styles.profileButton}
style={{ background: profileButtonBackground }}
onClick={handleButtonClick}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
{userDetails?.profileImageUrl ? (
<img
className={styles.profileAvatar}
src={userDetails.profileImageUrl}
alt={userDetails.displayName}
/>
) : (
<PersonIcon />
)}
</div>
<div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")}
</p>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning && (
<img
alt={gameRunning.title}
width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)}
</div>
</button>
);
}

View File

@ -125,46 +125,3 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileAvatar = style({
width: "30px",
height: "30px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});

View File

@ -13,7 +13,7 @@ import * as styles from "./sidebar.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { PersonIcon } from "@primer/octicons-react";
import { SidebarProfile } from "./sidebar-profile";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -154,18 +154,7 @@ export function Sidebar() {
maxWidth: sidebarWidth,
}}
>
<button type="button" className={styles.profileButton}>
<div className={styles.profileAvatar}>
<PersonIcon />
<div className={styles.statusBadge} />
</div>
<div className={styles.profileButtonInformation}>
<p style={{ fontWeight: "bold" }}>hydra</p>
<p style={{ fontSize: 12 }}>Jogando ABC</p>
</div>
</button>
<SidebarProfile />
<div
className={styles.content({

View File

@ -31,7 +31,7 @@ export const toast = recipe({
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
zIndex: "0",
zIndex: vars.zIndex.toast,
maxWidth: "500px",
},
variants: {

View File

@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Exodus";
export const VERSION_CODENAME = "Leviticus";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",

View File

@ -109,20 +109,19 @@ export function GameDetailsContextProvider({
}, [objectID, gameTitle, dispatch]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGameRunning) setisGameRunning(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGameRunning) setisGameRunning(true);
updateGame();
}
}),
];
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
const updatedIsGameRunning =
!!game?.id &&
!!gamesIds.find((gameRunning) => gameRunning.id == game.id);
if (isGameRunning != updatedIsGameRunning) {
updateGame();
}
setisGameRunning(updatedIsGameRunning);
});
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
unsubscribe();
};
}, [game?.id, isGameRunning, updateGame]);

View File

@ -13,6 +13,7 @@ import type {
StartGameDownloadPayload,
RealDebridUser,
DownloadSource,
UserProfile,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -70,8 +71,12 @@ declare global {
removeGame: (gameId: number) => Promise<void>;
deleteGameFolder: (gameId: number) => Promise<unknown>;
getGameByObjectID: (objectID: string) => Promise<Game | null>;
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
) => void
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
@ -95,6 +100,7 @@ declare global {
/* Misc */
openExternal: (src: string) => Promise<void>;
isUserLoggedIn: () => Promise<boolean>;
getVersion: () => Promise<string>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
@ -109,6 +115,23 @@ declare global {
) => () => Electron.IpcRenderer;
checkForUpdates: () => Promise<boolean>;
restartAndInstallUpdate: () => Promise<void>;
/* Auth */
signOut: () => Promise<void>;
openAuthWindow: () => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
/* Profile */
getMe: () => Promise<UserProfile | null>;
updateProfile: (
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
}
interface Window {

View File

@ -4,3 +4,5 @@ export * from "./use-preferences-slice";
export * from "./download-slice";
export * from "./window-slice";
export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./running-game-slice";

View File

@ -0,0 +1,22 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { GameRunning } from "@types";
export interface GameRunningState {
gameRunning: GameRunning | null;
}
const initialState: GameRunningState = {
gameRunning: null,
};
export const gameRunningSlice = createSlice({
name: "running-game",
initialState,
reducers: {
setGameRunning: (state, action: PayloadAction<GameRunning | null>) => {
state.gameRunning = action.payload;
},
},
});
export const { setGameRunning } = gameRunningSlice.actions;

View File

@ -0,0 +1,28 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import type { UserDetails } from "@types";
export interface UserDetailsState {
userDetails: UserDetails | null;
profileBackground: null | string;
}
const initialState: UserDetailsState = {
userDetails: null,
profileBackground: null,
};
export const userDetailsSlice = createSlice({
name: "user-details",
initialState,
reducers: {
setUserDetails: (state, action: PayloadAction<UserDetails | null>) => {
state.userDetails = action.payload;
},
setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload;
},
},
});
export const { setUserDetails, setProfileBackground } =
userDetailsSlice.actions;

View File

@ -1,5 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
@ -40,3 +42,6 @@ export const buildGameDetailsPath = (
const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
};
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();

View File

@ -3,3 +3,4 @@ export * from "./use-library";
export * from "./use-date";
export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";

View File

@ -1,4 +1,4 @@
import { formatDistance } from "date-fns";
import { formatDistance, subMilliseconds } from "date-fns";
import type { FormatDistanceOptions } from "date-fns";
import {
ptBR,
@ -52,5 +52,20 @@ export function useDate() {
return "";
}
},
formatDiffInMillis: (
millis: number,
baseDate: string | number | Date,
options?: FormatDistanceOptions
) => {
try {
return formatDistance(subMilliseconds(new Date(), millis), baseDate, {
...options,
locale: getDateLocale(),
});
} catch (err) {
return "";
}
},
};
}

View File

@ -0,0 +1,84 @@
import { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux";
import { setProfileBackground, setUserDetails } from "@renderer/features";
import { darkenColor } from "@renderer/helpers";
import { UserDetails } from "@types";
export function useUserDetails() {
const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector(
(state) => state.userDetails
);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
dispatch(setProfileBackground(null));
window.localStorage.removeItem("userDetails");
}, [dispatch]);
const signOut = useCallback(async () => {
clearUserDetails();
return window.electron.signOut();
}, [clearUserDetails]);
const updateUserDetails = useCallback(
async (userDetails: UserDetails) => {
dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, {
amount: 1,
format: "hex",
});
const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
"userDetails",
JSON.stringify({ ...userDetails, profileBackground })
);
} else {
const profileBackground = `#151515B3`;
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
"userDetails",
JSON.stringify({ ...userDetails, profileBackground })
);
}
},
[dispatch]
);
const fetchUserDetails = useCallback(async () => {
return window.electron.getMe();
}, []);
const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => {
const response = await window.electron.updateProfile(
displayName,
imageProfileUrl
);
return updateUserDetails(response);
},
[updateUserDetails]
);
return {
userDetails,
fetchUserDetails,
signOut,
clearUserDetails,
updateUserDetails,
patchUser,
profileBackground,
};
}

View File

@ -27,6 +27,7 @@ import {
import { store } from "./store";
import * as resources from "@locales";
import { User } from "./pages/user/user";
i18n
.use(LanguageDetector)
@ -54,6 +55,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/user/:userId" Component={User} />
</Route>
</Routes>
</HashRouter>

View File

@ -138,8 +138,9 @@ export const randomizerButton = style({
bottom: `${26 + SPACING_UNIT * 2}px`,
/* Scroll bar + spacing */
right: `${9 + SPACING_UNIT * 2}px`,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 3px",
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 1px",
border: `solid 2px ${vars.color.border}`,
zIndex: "1",
backgroundColor: vars.color.background,
":hover": {
backgroundColor: vars.color.background,
@ -149,6 +150,12 @@ export const randomizerButton = style({
":active": {
transform: "scale(0.98)",
},
":disabled": {
boxShadow: "none",
transform: "none",
opacity: "0.8",
backgroundColor: vars.color.background,
},
});
export const heroPanelSkeleton = style({

View File

@ -27,6 +27,7 @@ import { Downloader } from "@shared";
export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [randomizerLocked, setRandomizerLocked] = useState(false);
const { objectID } = useParams();
const [searchParams] = useSearchParams();
@ -54,6 +55,18 @@ export function GameDetails() {
{ fromRandomizer: "1" }
)
);
setRandomizerLocked(true);
const zero = performance.now();
requestAnimationFrame(function animateLock(time) {
if (time - zero <= 1000) {
requestAnimationFrame(animateLock);
} else {
setRandomizerLocked(false);
}
});
}
};
@ -118,7 +131,7 @@ export function GameDetails() {
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame}
disabled={!randomGame || randomizerLocked}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie

View File

@ -1,5 +1,4 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
@ -17,11 +16,9 @@ export const content = style({
flex: "1",
});
export const cards = recipe({
base: {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
},
export const cards = style({
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
});

View File

@ -65,7 +65,7 @@ export function AddDownloadSourceModal({
>
<TextField
label={t("download_source_url")}
placeholder="Insert a valid JSON url"
placeholder={t("insert_valid_json_url")}
value={value}
onChange={(e) => setValue(e.target.value)}
rightContent={
@ -99,14 +99,16 @@ export function AddDownloadSourceModal({
>
<h4>{validationResult?.name}</h4>
<small>
Found{" "}
{validationResult?.downloadCount.toLocaleString(undefined)}{" "}
download options
{t("found_download_option", {
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>
<Button type="button" onClick={handleAddDownloadSource}>
Import
{t("import")}
</Button>
</div>
)}

View File

@ -0,0 +1,318 @@
import { UserGame, UserProfile } from "@types";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import {
useAppSelector,
useDate,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react";
import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface ProfileContentProps {
userProfile: UserProfile;
updateUserProfile: () => Promise<void>;
}
export function UserContent({
userProfile,
updateUserProfile,
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails();
const { showSuccessToast } = useToast();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const navigate = useNavigate();
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const { formatDistance, formatDiffInMillis } = useDate();
const formatPlayTime = () => {
const seconds = userProfile.libraryGames.reduce(
(acc, game) => acc + game.playTimeInSeconds,
0
);
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
const handleGameClick = (game: UserGame) => {
navigate(buildGameDetailsPath(game));
};
const handleEditProfile = () => {
setShowEditProfileModal(true);
};
const handleConfirmSignout = async () => {
await signOut();
showSuccessToast(t("successfully_signed_out"));
navigate("/");
};
const isMe = userDetails?.id == userProfile.id;
const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */
return undefined;
}, [profileBackground]);
return (
<>
<UserEditProfileModal
visible={showEditProfileModal}
onClose={() => setShowEditProfileModal(false)}
updateUserProfile={updateUserProfile}
userProfile={userProfile}
/>
<UserSignOutModal
visible={showSignOutModal}
onClose={() => setShowSignOutModal(false)}
onConfirm={handleConfirmSignout}
/>
<section
className={styles.profileContentBox}
style={{
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
position: "relative",
}}
>
{gameRunning && isMe && (
<img
src={steamUrlBuilder.libraryHero(gameRunning.objectID)}
alt={gameRunning.title}
className={styles.profileBackground}
/>
)}
<div
style={{
background: profileContentBoxBackground,
position: "absolute",
inset: 0,
borderRadius: "4px",
}}
></div>
<div className={styles.profileAvatarContainer}>
{userProfile.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={userProfile.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</div>
<div className={styles.profileInformation}>
<h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2>
{isMe && gameRunning && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<Link to={buildGameDetailsPath(gameRunning)}>
{gameRunning.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDiffInMillis(
gameRunning.sessionDurationInMillis,
new Date()
),
})}
</small>
</div>
)}
</div>
{isMe && (
<div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button
theme="danger"
onClick={() => setShowSignOutModal(true)}
>
{t("sign_out")}
</Button>
</>
</div>
</div>
)}
</section>
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
{!userProfile.recentGames.length ? (
<div className={styles.noDownloads}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_recent_activity_description")}
</p>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{userProfile.recentGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.feedItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
>
<img
className={styles.feedGameIcon}
src={game.cover}
alt={game.title}
/>
<div className={styles.gameInformation}>
<h4>{game.title}</h4>
<small>
{t("last_time_played", {
period: formatDistance(
game.lastTimePlayed!,
new Date(),
{
addSuffix: true,
}
),
})}
</small>
</div>
</button>
))}
</div>
)}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,147 @@
import { Button, Modal, TextField } from "@renderer/components";
import { UserProfile } from "@types";
import * as styles from "./user.css";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfileModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
const [displayName, setDisplayName] = useState("");
const [newImagePath, setNewImagePath] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
setDisplayName(userProfile.displayName);
}, [userProfile.displayName]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp", "bmp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setNewImagePath(path);
}
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(displayName, newImagePath)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
cleanFormAndClose();
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const resetModal = () => {
setDisplayName(userProfile.displayName);
setNewImagePath(null);
};
const cleanFormAndClose = () => {
resetModal();
onClose();
};
const avatarUrl = useMemo(() => {
if (newImagePath) return `local:${newImagePath}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [newImagePath, userProfile.profileImageUrl]);
return (
<>
<Modal
visible={visible}
title={t("edit_profile")}
onClose={cleanFormAndClose}
>
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setDisplayName(e.target.value)}
/>
<Button
disabled={isSaving}
style={{ alignSelf: "end" }}
type="submit"
>
{isSaving ? t("saving") : t("save")}
</Button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1,40 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
visible: boolean;
onConfirm: () => void;
onClose: () => void;
}
export const UserSignOutModal = ({
visible,
onConfirm,
onClose,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p style={{ fontFamily: "Fira Sans" }}>{t("sign_out_modal_text")}</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("sign_out")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
</>
);
};

View File

@ -0,0 +1,41 @@
import Skeleton from "react-loading-skeleton";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export const UserSkeleton = () => {
const { t } = useTranslation("user_profile");
return (
<>
<Skeleton className={styles.profileHeaderSkeleton} />
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
height={72}
style={{ flex: "1", width: "100%" }}
/>
))}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<h2>{t("library")}</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={index} style={{ aspectRatio: "1" }} />
))}
</div>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,217 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const wrapper = style({
padding: "24px",
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
});
export const profileContentBox = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
export const profileAvatarContainer = style({
width: "96px",
height: "96px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
zIndex: 1,
});
export const profileAvatarEditContainer = style({
width: "128px",
height: "128px",
display: "flex",
borderRadius: "50%",
color: vars.color.body,
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
cursor: "pointer",
});
export const profileAvatar = style({
height: "100%",
width: "100%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover",
});
export const profileAvatarEditOverlay = style({
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "#00000055",
color: vars.color.muted,
zIndex: 1,
cursor: "pointer",
});
export const profileInformation = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
alignItems: "flex-start",
color: "#c0c1c7",
zIndex: 1,
});
export const profileContent = style({
display: "flex",
height: "100%",
flexDirection: "row",
gap: `${SPACING_UNIT * 4}px`,
});
export const profileGameSection = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const contentSidebar = style({
width: "100%",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "150px",
},
"(min-width: 1024px)": {
maxWidth: "250px",
width: "100%",
},
},
});
export const feedGameIcon = style({
height: "100%",
});
export const libraryGameIcon = style({
width: "100%",
height: "100%",
borderRadius: "4px",
});
export const feedItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
height: "72px",
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameListItem = style({
color: vars.color.body,
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
overflow: "hidden",
padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT / 2}px`,
});
export const profileHeaderSkeleton = style({
height: "144px",
});
export const editProfileImageBadge = style({
width: "28px",
height: "28px",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: vars.color.background,
backgroundColor: vars.color.muted,
position: "absolute",
bottom: "0px",
right: "0px",
zIndex: "1",
});
export const telescopeIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const noDownloads = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const signOutModalContent = style({
display: "flex",
width: "100%",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const signOutModalButtonsContainer = style({
display: "flex",
width: "100%",
justifyContent: "end",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
paddingTop: `${SPACING_UNIT}px`,
});
export const profileBackground = style({
width: "100%",
height: "100%",
position: "absolute",
objectFit: "cover",
left: "0",
top: "0",
borderRadius: "4px",
});

View File

@ -0,0 +1,45 @@
import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton";
import { UserContent } from "./user-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";
import * as styles from "./user.css";
export const User = () => {
const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>();
const dispatch = useAppDispatch();
const getUserProfile = useCallback(() => {
return window.electron.getUser(userId!).then((userProfile) => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile);
}
});
}, [dispatch, userId]);
useEffect(() => {
getUserProfile();
}, [getUserProfile]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.wrapper}>
{userProfile ? (
<UserContent
userProfile={userProfile}
updateUserProfile={getUserProfile}
/>
) : (
<UserSkeleton />
)}
</div>
</SkeletonTheme>
);
};

View File

@ -6,6 +6,8 @@ import {
searchSlice,
userPreferencesSlice,
toastSlice,
userDetailsSlice,
gameRunningSlice,
} from "@renderer/features";
export const store = configureStore({
@ -16,6 +18,8 @@ export const store = configureStore({
userPreferences: userPreferencesSlice.reducer,
download: downloadSlice.reducer,
toast: toastSlice.reducer,
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
},
});

View File

@ -21,4 +21,10 @@ export const vars = createGlobalTheme(":root", {
body: "14px",
small: "12px",
},
zIndex: {
toast: "2",
bottomPanel: "3",
titleBar: "4",
backdrop: "4",
},
});

View File

@ -85,6 +85,16 @@ export interface CatalogueEntry {
repacks: GameRepack[];
}
export interface UserGame {
objectID: string;
shop: GameShop;
title: string;
iconUrl: string | null;
cover: string;
playTimeInSeconds: number;
lastTimePlayed: Date | null;
}
export interface DownloadQueue {
id: number;
createdAt: Date;
@ -117,6 +127,15 @@ export interface Game {
export type LibraryGame = Omit<Game, "repacks">;
export interface GameRunning {
id: number;
title: string;
iconUrl: string;
objectID: string;
shop: GameShop;
sessionDurationInMillis: number;
}
export interface DownloadProgress {
downloadSpeed: number;
timeRemaining: number;
@ -234,6 +253,20 @@ export interface RealDebridUser {
expiration: string;
}
export interface UserDetails {
id: string;
displayName: string;
profileImageUrl: string | null;
}
export interface UserProfile {
id: string;
displayName: string;
profileImageUrl: string | null;
libraryGames: UserGame[];
recentGames: UserGame[];
}
export interface DownloadSource {
id: number;
name: string;

115
yarn.lock
View File

@ -1251,6 +1251,25 @@
"@types/node" "*"
"@types/responselike" "^1.0.0"
"@types/color-convert@*":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.3.tgz#e93f5c991eda87a945058b47044f5f0008b0dce9"
integrity sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==
dependencies:
"@types/color-name" "*"
"@types/color-name@*":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.4.tgz#e002611ff627347818d440a05e81650e9a4053b8"
integrity sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg==
"@types/color@^3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/color/-/color-3.0.6.tgz#29c27a99d4de2975e1676712679a0bd7f646a3fb"
integrity sha512-NMiNcZFRUAiUUCCf7zkAelY8eV3aKqfbzyFQlXpPIEeoNDbsEHGpb854V3gzTsGKYj830I5zPuOwU/TP5/cW6A==
dependencies:
"@types/color-convert" "*"
"@types/conventional-commits-parser@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz#8c9d23e0b415b24b91626d07017303755d542dc8"
@ -1296,6 +1315,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/jsonwebtoken@^9.0.6":
version "9.0.6"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz#d1af3544d99ad992fb6681bbe60676e06b032bd3"
integrity sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==
dependencies:
"@types/node" "*"
"@types/keyv@^3.1.4":
version "3.1.4"
resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6"
@ -2037,6 +2063,11 @@ buffer-crc32@~0.2.3:
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
buffer-equal@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.1.tgz#2f7651be5b1b3f057fcd6e7ee16cf34767077d90"
@ -2705,6 +2736,13 @@ eastasianwidth@^0.2.0:
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
ejs@^3.1.8:
version "3.1.10"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b"
@ -3305,6 +3343,15 @@ file-type@^18.7.0:
strtok3 "^7.0.0"
token-types "^5.0.1"
file-type@^19.0.0:
version "19.0.0"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-19.0.0.tgz#62a6cadc43f73ba38c53e1a174943a75fdafafa9"
integrity sha512-s7cxa7/leUWLiXO78DVVfBVse+milos9FitauDLG1pI7lNaJ2+5lzPnr2N24ym+84HVwJL6hVuGfgVE+ALvU8Q==
dependencies:
readable-web-to-node-stream "^3.0.2"
strtok3 "^7.0.0"
token-types "^5.0.1"
file-uri-to-path@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@ -4267,6 +4314,22 @@ jsonparse@^1.2.0:
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
jsonwebtoken@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3"
integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==
dependencies:
jws "^3.2.2"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^7.5.4"
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5:
version "3.3.5"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"
@ -4277,6 +4340,23 @@ jsonparse@^1.2.0:
object.assign "^4.1.4"
object.values "^1.1.6"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
keyv@^4.0.0, keyv@^4.5.3:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@ -4348,16 +4428,41 @@ lodash.escaperegexp@^4.1.2:
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
lodash.kebabcase@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
@ -4373,6 +4478,11 @@ lodash.mergewith@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
lodash.snakecase@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
@ -4633,6 +4743,11 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
mz@^2.4.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"