Merge branch 'main' into lang-ca

This commit is contained in:
Zamitto 2024-06-27 10:55:02 -03:00 committed by GitHub
commit 7aa02f9d64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 2756 additions and 740 deletions

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",
@ -119,7 +110,9 @@
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option"
"last_downloaded_option": "Last downloaded option",
"create_shortcut_success": "Shortcut created successfully",
"create_shortcut_error": "Error creating shortcut"
},
"activation": {
"title": "Activate Hydra",
@ -135,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",
@ -163,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",
@ -198,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",
@ -224,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": "Sesión iniciada correctamente"
},
"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": "Iniciar sesión"
},
"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",
@ -119,7 +110,9 @@
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra",
"download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción descargada"
"last_downloaded_option": "Última opción descargada",
"create_shortcut_success": "Atajo creado con éxito",
"create_shortcut_error": "Error al crear un atajo"
},
"activation": {
"title": "Activar Hydra",
@ -135,18 +128,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 +152,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 +185,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 descargas están actualizadas.",
"insert_valid_json_url": "Introduce una URL JSON válida",
"found_download_option_zero": "No se encontró una opción de descarga",
"found_download_option_one": "Se encontró {{countFormatted}} opción de descarga",
"found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga",
"import": "Importar"
},
"notifications": {
"download_complete": "Descarga completada",
@ -224,5 +216,27 @@
},
"forms": {
"toggle_password_visibility": "Cambiar visibilidad de contraseña"
},
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"last_time_played": "Última vez jugado {{period}}",
"activity": "Actividad reciente",
"library": "Biblioteca",
"total_play_time": "Total de tiempo jugado: {{amount}}",
"no_recent_activity_title": "Que raro, no hay nada por acá, ¿que tal si jugamos algo para empezar?",
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
"display_name": "Nombre a mostrar",
"saving": "Guardando",
"save": "Guardar",
"edit_profile": "Editar perfil",
"saved_successfully": "Guardado exitosamente",
"try_again": "Por favor, intenta de nuevo",
"sign_out_modal_title": "¿Estás seguro?",
"cancel": "Cancelar",
"successfully_signed_out": "Sesión cerrada exitosamente",
"sign_out": "Cerrar sesión",
"playing_for": "Jugando por {{amount}}",
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?"
}
}

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",
@ -116,7 +106,9 @@
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada"
"last_downloaded_option": "Última opção baixada",
"create_shortcut_success": "Atalho criado com sucesso",
"create_shortcut_error": "Erro ao criar atalho"
},
"activation": {
"title": "Ativação",
@ -132,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?",
@ -160,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",
@ -195,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",
@ -225,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": "Успешный вход"
},
"home": {
"featured": "Рекомендованное",
"trending": "В тренде",
@ -16,7 +19,8 @@
"filter": "Фильтр библиотеки",
"home": "Главная",
"queued": "{{title}} (В очереди)",
"game_has_no_executable": "Файл запуска игры не выбран"
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти"
},
"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": "Путь для загрузок",
@ -119,7 +110,9 @@
"danger_zone_section_description": "Удалить эту игру из вашей библиотеки или файлы скачанные Hydra",
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки"
"last_downloaded_option": "Последний вариант загрузки",
"create_shortcut_success": "Ярлык создан",
"create_shortcut_error": "Не удалось создать ярлык"
},
"activation": {
"title": "Активировать Hydra",
@ -135,18 +128,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,14 +152,12 @@
"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 вместе с системой",
"general": "Основные",
"behavior": "Поведение",
"download_sources": "Скачать исходный код",
"download_sources": "Источники загрузки",
"language": "Язык",
"real_debrid_api_token": "API Ключ",
"enable_real_debrid": "Включить Real-Debrid",
@ -198,7 +185,12 @@
"sync_download_sources": "Синхронизировать источники",
"removed_download_source": "Источник загрузок удален",
"added_download_source": "Источник загрузок добавлен",
"download_sources_synced": "Все источники загрузок синхронизированы"
"download_sources_synced": "Все источники загрузок синхронизированы",
"insert_valid_json_url": "Вставьте действительный URL JSON-файла",
"found_download_option_zero": "Не найдено вариантов загрузки",
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
"import": "Импортировать"
},
"notifications": {
"download_complete": "Загрузка завершена",
@ -224,5 +216,27 @@
},
"forms": {
"toggle_password_visibility": "Показывать пароль"
},
"user_profile": {
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"last_time_played": "Последняя игра {{period}}",
"activity": "Недавняя активность",
"library": "Библиотека",
"total_play_time": "Всего сыграно: {{amount}}",
"no_recent_activity_title": "Хммм... Тут ничего нет",
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя",
"saving": "Сохранение",
"save": "Сохранено",
"edit_profile": "Редактировать Профиль",
"saved_successfully": "Успешно сохранено",
"try_again": "Пожалуйста, попробуйте ещё раз",
"sign_out_modal_title": "Вы уверены?",
"cancel": "Отменить",
"successfully_signed_out": "Успешный выход из аккаунта",
"sign_out": "Выйти",
"playing_for": "Сыграно {{amount}}",
"sign_out_modal_text": "Ваша библиотека связана с текущей учетной записью. При выходе из системы ваша библиотека станет недоступна, и прогресс не будет сохранен. Выйти?"
}
}

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,9 @@
{
"app": {
"successfully_signed_in": "Успішний вхід в систему"
},
"home": {
"featured": "Рекомендоване",
"recently_added": "Нове",
"trending": "У тренді",
"surprise_me": "Здивуй мене",
"no_results": "Результатів не знайдено"
@ -15,12 +17,10 @@
"paused": "{{title}} (Призупинено)",
"downloading": "{{title}} ({{percentage}} - Завантаження…)",
"filter": "Фільтр бібліотеки",
"follow_us": "Підписуйтесь на нас",
"home": "Головна",
"discord": "Приєднуйтесь до Discord",
"telegram": "Приєднуйтесь до Telegram",
"x": "Підписуйтесь на X",
"github": "Зробіть свій внесок на GitHub"
"game_has_no_executable": "Не було вибрано файл для запуску гри",
"queued": "{{title}} в черзі",
"sign_in": "Увійти"
},
"header": {
"search": "Пошук",
@ -28,12 +28,15 @@
"catalogue": "Каталог",
"downloads": "Завантаження",
"search_results": "Результати пошуку",
"settings": "Налаштування"
"settings": "Налаштування",
"version_available_download": "Доступна версія {{version}}. Натисніть тут, щоб перезапустити та встановити.",
"version_available_install": "Доступна версія {{version}}. Натисніть тут для завантаження."
},
"bottom_panel": {
"no_downloads_in_progress": "Немає активних завантажень",
"downloading_metadata": "Завантаження метаданих {{title}}…",
"downloading": "Завантаження {{title}}… ({{percentage}} завершено) - Закінчення {{eta}} - {{speed}}"
"downloading": "Завантаження {{title}}… ({{percentage}} завершено) - Закінчення {{eta}} - {{speed}}",
"calculating_eta": "Завантаження {{title}}… ({{percentage}} завершено) - Обчислення залишкового часу…"
},
"catalogue": {
"next_page": "Наступна сторінка",
@ -50,7 +53,6 @@
"pause": "Призупинити",
"cancel": "Скасувати",
"remove": "Видалити",
"remove_from_list": "Видалити",
"space_left_on_disk": "{{space}} вільно на диску",
"eta": "Закінчення {{eta}}",
"downloading_metadata": "Завантаження метаданих…",
@ -58,12 +60,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,20 +82,41 @@
"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": "Зрозуміло"
"calculating_eta": "Обчислення залишкового часу…",
"create_shortcut": "Створити ярлик на робочому столі",
"danger_zone_section_description": "Видалити цю гру з вашої бібліотеки або файли скачані Hydra",
"danger_zone_section_title": "Небезпечна зона",
"download_in_progress": "Триває завантаження.",
"download_options": "Варіантів завантаження",
"download_path": "Тека для завантажень",
"download_paused": "Завантаження призупинено",
"download_settings": "Налаштування завантаження",
"downloader": "Завантажувач",
"downloads_secion_title": "Завантаження",
"downloads_section_description": "Перевірити наявність оновлень або інших версій гри",
"executable_section_description": "Шлях до файлу, який буде запущений при натисканні на кнопку \"Play\"",
"executable_section_title": "Файл",
"last_downloaded_option": "Останній варіант завантаження",
"next_screenshot": "Наступний скрішнот",
"no_executable_selected": "Файл не вибрано",
"no_shop_details": "Не вдалося отримати опис",
"open_download_location": "Переглянути папку завантажень",
"open_folder": "Відкрити папку",
"open_screenshot": "Відкрити скріншот",
"options": "Налаштування",
"paused": "Призупинено",
"previous_screenshot": "Попередній скріншот",
"remove_files": "Видалити файли",
"remove_from_library_description": "{{game}} буде видалено з вашої бібліотеки",
"remove_from_library_title": "Ви впевнені?",
"screenshot": "Скріншот",
"select_executable": "Обрати"
},
"activation": {
"title": "Активувати Hydra",
"installation_id": "ID установки:",
"enter_activation_code": "Введіть ваш активаційний код",
"message": "Якщо ви не знаєте, де його запросити, то не повинні мати цього.",
"message": "Якщо ви не знаєте, де його запросити, то не повинні мати його.",
"activate": "Активувати",
"loading": "Завантаження…"
},
@ -107,20 +126,23 @@
"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": "Встановити"
"install": "Встановити",
"download_in_progress": "В процесі",
"downloads_completed": "Завершено",
"no_downloads_description": "Ви ще нічого не завантажили через Hydra, але ніколи не пізно почати.",
"no_downloads_title": "Тут так пусто...",
"queued": "В черзі",
"queued_downloads": "Завантаження в черзі",
"removed": "Не завантажено"
},
"settings": {
"downloads_path": "Тека завантажень",
@ -128,11 +150,45 @@
"notifications": "Повідомлення",
"enable_download_notifications": "Після завершення завантаження",
"enable_repack_list_notifications": "Коли додається новий репак",
"telemetry": "Телеметрія",
"telemetry_description": "Відправляти анонімну статистику використання",
"behavior": "Поведінка",
"quit_app_instead_hiding": "Закривати програму замість того, щоб згортати її в трей",
"launch_with_system": "Запускати програми із запуском комп'ютера"
"quit_app_instead_hiding": "Закривати Hydra замість того, щоб згортати її в трей",
"launch_with_system": "Запускати Hydra із запуском комп'ютера",
"add_download_source": "Добавити джерело",
"add_download_source_description": "Введіть посилання на .json-файл",
"added_download_source": "Джерело для завантаження було додано",
"changes_saved": "Зміни успішно збережено",
"download_count_one": "{{countFormatted}} завантаження в списку",
"download_count_other": "{{countFormatted}} завантажень в списку",
"download_count_zero": "В списку немає завантажень",
"download_options_one": "{{countFormatted}} доступний варіант завантаження",
"download_options_other": "{{countFormatted}} доступних варіантів завантаження",
"download_options_zero": "Немає доступних завантажень",
"download_source_errored": "Помилка",
"download_source_up_to_date": "Оновлено",
"download_source_url": "Посилання на джерело",
"download_sources": "Джерела для завантаження",
"download_sources_description": "Hydra буде отримувати посилання для завантажень із цих джерел. URL має містити пряме посилання на .json-файл із посиланнями для завантажень.",
"download_sources_synced": "Всі джерела для завантаження синхронізовано",
"enable_real_debrid": "Включити Real-Debrid",
"found_download_option_one": "Знайдено {{countFormatted}} варіант завантаження",
"found_download_option_other": "Знайдено {{countFormatted}} варіантів завантаження",
"found_download_option_zero": "Немає доступних завантажень",
"general": "Основні",
"import": "Імпортувати",
"insert_valid_json_url": "Вставте дійсний URL JSON-файлу",
"language": "Мова",
"real_debrid_api_token": "API-токен",
"real_debrid_api_token_hint": "API токен можливо отримати <0>тут</0>",
"real_debrid_api_token_label": "Real-Debrid API-токен",
"real_debrid_description": "Real-Debrid — це необмежений завантажувач, який дозволяє швидко завантажувати файли, розміщені в Інтернеті, або миттєво передавати їх у плеєр через приватну мережу, що дозволяє обходити будь-які блокування.",
"real_debrid_free_account_error": "Акаунт \"{{username}}\" - не має наявної підписки. Будь ласка, оформіть підписку на Real-Debrid",
"real_debrid_invalid_token": "Невірний API-токен",
"real_debrid_linked_message": "Акаунт \"{{username}}\" привязаний",
"remove_download_source": "Видалити",
"removed_download_source": "Джерело завантажень було видалено",
"save_changes": "Зберегти зміни",
"sync_download_sources": "Синхронізувати джерела",
"validate_download_source": "Перевірити"
},
"notifications": {
"download_complete": "Завантаження завершено",
@ -155,5 +211,30 @@
},
"modal": {
"close": "Закрити"
},
"forms": {
"toggle_password_visibility": "Показувати пароль"
},
"user_profile": {
"activity": "Остання активність",
"amount_hours": "{{amount}} годин",
"amount_minutes": "{{amount}} хвилин",
"cancel": "Скасувати",
"display_name": "Відображуване ім'я",
"edit_profile": "Редагувати профіль",
"last_time_played": "Остання гра {{period}}",
"library": "Бібліотека",
"no_recent_activity_description": "Ви давно не грали в ігри. Пора це змінити!",
"no_recent_activity_title": "Хммм... Тут нічого немає",
"playing_for": "Зіграно {{amount}}",
"save": "Збережено",
"saved_successfully": "Успішно збережено",
"saving": "Збереження",
"sign_out": "Вийти",
"sign_out_modal_text": "Ваша бібліотека пов'язана з поточним обліковим записом. При виході з системи ваша бібліотека буде недоступною, і прогрес не буде збережено. Продовжити вихід?",
"sign_out_modal_title": "Ви впевнені?",
"successfully_signed_out": "Успішний вихід з акаунту",
"total_play_time": "Всього зіграно: {{amount}}",
"try_again": "Будь ласка, попробуйте ще раз"
}
}

View file

@ -1,7 +1,9 @@
{
"app": {
"successfully_signed_in": "已成功登录"
},
"home": {
"featured": "特色推荐",
"recently_added": "最近添加",
"trending": "最近热门",
"surprise_me": "向我推荐",
"no_results": "没有找到结果"
@ -15,25 +17,26 @@
"paused": "{{title}} (已暂停)",
"downloading": "{{title}} ({{percentage}} - 正在下载…)",
"filter": "筛选游戏库",
"follow_us": "关注我们",
"home": "主页",
"discord": "加入我们的Discord",
"telegram": "加入我们的Telegram",
"x": "在X上关注我们",
"github": "在GitHub上贡献"
"queued": "{{title}} (已加入下载队列)",
"game_has_no_executable": "未选择游戏的可执行文件",
"sign_in": "登入"
},
"header": {
"search": "搜索",
"search": "搜索游戏",
"home": "主页",
"catalogue": "游戏目录",
"downloads": "下载中心",
"search_results": "搜索结果",
"settings": "设置"
"settings": "设置",
"version_available_install": "版本 {{version}} 已可用. 点击此处重新启动并安装.",
"version_available_download": "版本 {{version}} 可用. 点击此处下载."
},
"bottom_panel": {
"no_downloads_in_progress": "没有正在进行的下载",
"downloading_metadata": "正在下载{{title}}的元数据…",
"downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}"
"downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}",
"calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间..."
},
"catalogue": {
"next_page": "下一页",
@ -50,7 +53,6 @@
"pause": "暂停",
"cancel": "取消",
"remove": "移除",
"remove_from_list": "从列表中移除",
"space_left_on_disk": "磁盘剩余空间{{space}}",
"eta": "预计完成时间{{eta}}",
"downloading_metadata": "正在下载元数据…",
@ -58,12 +60,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,17 +82,32 @@
"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}}",
"open_screenshot": "打开截图 {{number}}"
"open_screenshot": "打开截图 {{number}}",
"download_settings": "下载设置",
"downloader": "下载器",
"select_executable": "选择",
"no_executable_selected": "没有可执行文件被指定",
"open_folder": "打开目录",
"open_download_location": "查看已下载的文件",
"create_shortcut": "创建桌面快捷方式",
"remove_files": "删除文件",
"remove_from_library_title": "你确定吗?",
"remove_from_library_description": "这将会把 {{game}} 从你的库中移除",
"options": "选项",
"executable_section_title": "可执行文件",
"executable_section_description": "点击 \"Play\" 时将执行的文件的路径",
"downloads_secion_title": "下载",
"downloads_section_description": "查看此游戏的更新或其他版本",
"danger_zone_section_title": "危险操作",
"danger_zone_section_description": "从您的库或Hydra下载的文件中删除此游戏",
"download_in_progress": "下载进行中",
"download_paused": "下载暂停",
"last_downloaded_option": "上次下载的选项",
"create_shortcut_success": "成功创建快捷方式",
"create_shortcut_error": "创建快捷方式出错"
},
"activation": {
"title": "激活 Hydra",
@ -110,20 +123,22 @@
"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": "安装"
"install": "安装",
"download_in_progress": "进行中",
"queued_downloads": "在队列中的下载",
"downloads_completed": "已完成",
"queued": "下载列表",
"no_downloads_title": "空空如也",
"no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。"
},
"settings": {
"downloads_path": "下载路径",
@ -131,36 +146,72 @@
"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",
"general": "通用",
"behavior": "行为",
"general": "常规",
"quit_app_instead_hiding": "关闭应用程序而不是最小化到托盘",
"launch_with_system": "随系统启动时运行应用程序",
"download_sources": "下载源",
"language": "语言",
"real_debrid_api_token": "API 令牌",
"enable_real_debrid": "启用 Real-Debrid",
"real_debrid_description": "Real-Debrid 是一个无限制的下载器,允许您以最快的互联网速度即时下载文件。",
"real_debrid_invalid_token": "无效的 API 令牌",
"real_debrid_api_token_hint": "您可以从<0>这里</0>获取API密钥.",
"save_changes": "保存更改"
},
"notifications": {
"download_complete": "下载完成",
"game_ready_to_install": "{{title}}已准备好安装",
"repack_list_updated": "重打包列表已更新",
"repack_count_one": "已添加{{count}}个重打包",
"repack_count_other": "已添加{{count}}个重打包"
},
"system_tray": {
"open": "打开Hydra",
"quit": "退出"
},
"game_card": {
"no_downloads": "没有可用的下载"
},
"binary_not_found_modal": {
"title": "程序未安装",
"description": "在您的系统上未找到Wine或Lutris的可执行文件",
"instructions": "检查在您的Linux发行版上正确安装它们的方法,以便游戏可以正常运行"
"real_debrid_free_account_error": "账户 \"{{username}}\" 是免费账户。请订阅 Real-Debrid",
"real_debrid_linked_message": "账户 \"{{username}}\" 已链接",
"save_changes": "保存更改",
"changes_saved": "更改已成功保存",
"download_sources_description": "Hydra 将从这些源获取下载链接。源 URL 必须是直接链接到包含下载链接的 .json 文件。",
"validate_download_source": "验证",
"remove_download_source": "移除",
"add_download_source": "添加源",
"download_count_zero": "列表中无下载",
"download_count_one": "列表中有 {{countFormatted}} 个下载",
"download_count_other": "列表中有 {{countFormatted}} 个下载",
"download_options_zero": "无可用下载",
"download_options_one": "有 {{countFormatted}} 个下载可用",
"download_options_other": "有 {{countFormatted}} 个下载可用",
"download_source_url": "下载源 URL",
"add_download_source_description": "插入包含 .json 文件的 URL",
"download_source_up_to_date": "已更新",
"download_source_errored": "出错",
"sync_download_sources": "同步源",
"removed_download_source": "已移除下载源",
"added_download_source": "已添加下载源",
"download_sources_synced": "所有下载源已同步",
"insert_valid_json_url": "插入有效的 JSON 网址",
"found_download_option_zero": "未找到下载选项",
"found_download_option_one": "找到 {{countFormatted}} 个下载选项",
"found_download_option_other": "找到 {{countFormatted}} 个下载选项",
"import": "导入"
},
"modal": {
"close": "关闭按钮"
},
"forms": {
"toggle_password_visibility": "切换密码可见性"
},
"user_profile": {
"amount_hours": "{{amount}} 小时",
"amount_minutes": "{{amount}} 分钟",
"last_time_played": "上次游玩时间 {{period}}",
"activity": "近期活动",
"library": "库",
"total_play_time": "总游戏时长: {{amount}}",
"no_recent_activity_title": "Emmm… 这里暂时啥都没有",
"no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!",
"display_name": "昵称",
"saving": "保存中",
"save": "保存",
"edit_profile": "编辑资料",
"saved_successfully": "成功保存",
"try_again": "请重试",
"sign_out_modal_title": "你确定吗?",
"cancel": "取消",
"successfully_signed_out": "登出成功",
"sign_out": "登出",
"playing_for": "Playing for {{amount}}",
"sign_out_modal_text": "您的资料库与您当前的账户相关联。注销后,您的资料库将不再可见,任何进度也不会保存。继续退出吗?"
}
}

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

@ -5,7 +5,6 @@ export const downloadSourceSchema = z.object({
downloads: z.array(
z.object({
title: z.string().max(255),
downloaders: z.array(z.enum(["real_debrid", "torrent"])),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),

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,6 +73,15 @@ app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
const handleDeepLinkPath = (uri?: string) => {
if (!uri) return;
const url = new URL(uri);
if (url.host === "install-source") {
WindowManager.redirect(`settings${url.search}`);
}
};
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
@ -82,13 +93,11 @@ app.on("second-instance", (_event, commandLine) => {
WindowManager.createMainWindow();
}
const [, path] = commandLine.pop()?.split("://") ?? [];
if (path) WindowManager.redirect(path);
handleDeepLinkPath(commandLine.pop());
});
app.on("open-url", (_event, url) => {
const [, path] = url.split("://");
WindowManager.redirect(path);
handleDeepLinkPath(url);
});
// Quit when all windows are closed, except on macOS. There, it's common

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

@ -9,12 +9,13 @@ import {
shell,
} from "electron";
import { is } from "@electron-toolkit/utils";
import { t } from "i18next";
import i18next, { t } from "i18next";
import path from "node:path";
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,48 @@ 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,
nodeIntegrationInSubFrames: true,
},
});
authWindow.removeMenu();
const searchParams = new URLSearchParams({
lng: i18next.language,
});
authWindow.loadURL(
`https://auth.hydra.losbroxas.org/?${searchParams.toString()}`
);
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://*.cloudfront.net 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 = "ELDEN RING";
const FEATURED_GAME_ID = "1245620";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] =
@ -54,7 +54,7 @@ export function Hero() {
>
<div className={styles.backdrop}>
<img
src={steamUrlBuilder.libraryHero(FEATURED_GAME_ID)}
src="https://cdn2.steamgriddb.com/hero/95eb39b541856d43649b208b65b6ca9f.jpg"
alt={FEATURED_GAME_TITLE}
className={styles.heroMedia}
/>

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

@ -1 +1,2 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";

View file

@ -0,0 +1,73 @@
import { createContext, useEffect, useState } from "react";
import { setUserPreferences } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import type { UserPreferences } from "@types";
import { useSearchParams } from "react-router-dom";
export interface SettingsContext {
updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
clearSourceUrl: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
}
export const settingsContext = createContext<SettingsContext>({
updateUserPreferences: async () => {},
setCurrentCategoryIndex: () => {},
clearSourceUrl: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
});
const { Provider } = settingsContext;
export const { Consumer: SettingsContextConsumer } = settingsContext;
export interface SettingsContextProviderProps {
children: React.ReactNode;
}
export function SettingsContextProvider({
children,
}: SettingsContextProviderProps) {
const dispatch = useAppDispatch();
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
useEffect(() => {
if (sourceUrl) setCurrentCategoryIndex(2);
}, [sourceUrl]);
useEffect(() => {
if (defaultSourceUrl) {
setSourceUrl(defaultSourceUrl);
}
}, [defaultSourceUrl]);
const clearSourceUrl = () => setSourceUrl(null);
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
await window.electron.updateUserPreferences(values);
window.electron.getUserPreferences().then((userPreferences) => {
dispatch(setUserPreferences(userPreferences));
});
};
return (
<Provider
value={{
updateUserPreferences,
setCurrentCategoryIndex,
clearSourceUrl,
currentCategoryIndex,
sourceUrl,
}}
>
{children}
</Provider>
);
}

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

@ -5,7 +5,7 @@ import type { Game } from "@types";
import * as styles from "./game-options-modal.css";
import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload } from "@renderer/hooks";
import { useDownload, useToast } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
export interface GameOptionsModalProps {
@ -21,6 +21,8 @@ export function GameOptionsModal({
}: GameOptionsModalProps) {
const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const { updateGame, setShowRepacksModal, selectGameExecutable } =
useContext(gameDetailsContext);
@ -61,7 +63,13 @@ export function GameOptionsModal({
};
const handleCreateShortcut = async () => {
await window.electron.createGameShortcut(game.id);
window.electron.createGameShortcut(game.id).then((success) => {
if (success) {
showSuccessToast(t("create_shortcut_success"));
} else {
showErrorToast(t("create_shortcut_error"));
}
});
};
const handleOpenDownloadFolder = async () => {

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

@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
interface AddDownloadSourceModalProps {
visible: boolean;
@ -23,24 +24,31 @@ export function AddDownloadSourceModal({
downloadCount: number;
} | null>(null);
useEffect(() => {
setValue("");
setIsLoading(false);
setValidationResult(null);
}, [visible]);
const { t } = useTranslation("settings");
const handleValidateDownloadSource = async () => {
const { sourceUrl } = useContext(settingsContext);
const handleValidateDownloadSource = useCallback(async (url: string) => {
setIsLoading(true);
try {
const result = await window.electron.validateDownloadSource(value);
const result = await window.electron.validateDownloadSource(url);
setValidationResult(result);
} finally {
setIsLoading(false);
}
};
}, []);
useEffect(() => {
setValue("");
setIsLoading(false);
setValidationResult(null);
if (sourceUrl) {
setValue(sourceUrl);
handleValidateDownloadSource(sourceUrl);
}
}, [visible, handleValidateDownloadSource, sourceUrl]);
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(value);
@ -65,7 +73,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={
@ -73,7 +81,7 @@ export function AddDownloadSourceModal({
type="button"
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={handleValidateDownloadSource}
onClick={() => handleValidateDownloadSource(value)}
disabled={isLoading || !value}
>
{t("validate_download_source")}
@ -99,14 +107,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

@ -1,22 +1,17 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { UserPreferences } from "@types";
import { CheckboxField } from "@renderer/components";
import { useAppSelector } from "@renderer/hooks";
import { settingsContext } from "@renderer/context";
export interface SettingsBehaviorProps {
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsBehavior({
updateUserPreferences,
}: SettingsBehaviorProps) {
export function SettingsBehavior() {
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateUserPreferences } = useContext(settingsContext);
const [form, setForm] = useState({
preferQuitInsteadOfHiding: false,
runAtStartup: false,

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { TextField, Button, Badge } from "@renderer/components";
import { useTranslation } from "react-i18next";
@ -10,6 +10,7 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@ -18,8 +19,9 @@ export function SettingsDownloadSources() {
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
useState(false);
const { t } = useTranslation("settings");
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const getDownloadSources = async () => {
@ -32,6 +34,10 @@ export function SettingsDownloadSources() {
getDownloadSources();
}, []);
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
showSuccessToast(t("removed_download_source"));
@ -63,11 +69,16 @@ export function SettingsDownloadSources() {
[DownloadSourceStatus.Errored]: t("download_source_errored"),
};
const handleModalClose = () => {
clearSourceUrl();
setShowAddDownloadSourceModal(false);
};
return (
<>
<AddDownloadSourceModal
visible={showAddDownloadSourceModal}
onClose={() => setShowAddDownloadSourceModal(false)}
onClose={handleModalClose}
onAddDownloadSource={handleAddDownloadSource}
/>

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import ISO6391 from "iso-639-1";
import {
@ -8,27 +8,24 @@ import {
SelectField,
} from "@renderer/components";
import { useTranslation } from "react-i18next";
import type { UserPreferences } from "@types";
import { useAppSelector } from "@renderer/hooks";
import { changeLanguage } from "i18next";
import * as languageResources from "@locales";
import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context";
interface LanguageOption {
option: string;
nativeName: string;
}
export interface SettingsGeneralProps {
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsGeneral({
updateUserPreferences,
}: SettingsGeneralProps) {
export function SettingsGeneral() {
const { t } = useTranslation("settings");
const { updateUserPreferences } = useContext(settingsContext);
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);

View file

@ -1,26 +1,23 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
import * as styles from "./settings-real-debrid.css";
import type { UserPreferences } from "@types";
import { useAppSelector, useToast } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken";
export interface SettingsRealDebridProps {
updateUserPreferences: (values: Partial<UserPreferences>) => void;
}
export function SettingsRealDebrid({
updateUserPreferences,
}: SettingsRealDebridProps) {
export function SettingsRealDebrid() {
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateUserPreferences } = useContext(settingsContext);
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
useRealDebrid: false,

View file

@ -1,21 +1,20 @@
import { useState } from "react";
import { Button } from "@renderer/components";
import * as styles from "./settings.css";
import { useTranslation } from "react-i18next";
import { UserPreferences } from "@types";
import { SettingsRealDebrid } from "./settings-real-debrid";
import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior";
import { useAppDispatch } from "@renderer/hooks";
import { setUserPreferences } from "@renderer/features";
import { SettingsDownloadSources } from "./settings-download-sources";
import {
SettingsContextConsumer,
SettingsContextProvider,
} from "@renderer/context";
export function Settings() {
const { t } = useTranslation("settings");
const dispatch = useAppDispatch();
const categories = [
t("general"),
t("behavior"),
@ -23,57 +22,50 @@ export function Settings() {
"Real-Debrid",
];
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const handleUpdateUserPreferences = async (
values: Partial<UserPreferences>
) => {
await window.electron.updateUserPreferences(values);
window.electron.getUserPreferences().then((userPreferences) => {
dispatch(setUserPreferences(userPreferences));
});
};
const renderCategory = () => {
if (currentCategoryIndex === 0) {
return (
<SettingsGeneral updateUserPreferences={handleUpdateUserPreferences} />
);
}
if (currentCategoryIndex === 1) {
return (
<SettingsBehavior updateUserPreferences={handleUpdateUserPreferences} />
);
}
if (currentCategoryIndex === 2) {
return <SettingsDownloadSources />;
}
return (
<SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} />
);
};
return (
<section className={styles.container}>
<div className={styles.content}>
<section className={styles.settingsCategories}>
{categories.map((category, index) => (
<Button
key={category}
theme={currentCategoryIndex === index ? "primary" : "outline"}
onClick={() => setCurrentCategoryIndex(index)}
>
{category}
</Button>
))}
</section>
<SettingsContextProvider>
<SettingsContextConsumer>
{({ currentCategoryIndex, setCurrentCategoryIndex }) => {
const renderCategory = () => {
if (currentCategoryIndex === 0) {
return <SettingsGeneral />;
}
<h2>{categories[currentCategoryIndex]}</h2>
{renderCategory()}
</div>
</section>
if (currentCategoryIndex === 1) {
return <SettingsBehavior />;
}
if (currentCategoryIndex === 2) {
return <SettingsDownloadSources />;
}
return <SettingsRealDebrid />;
};
return (
<section className={styles.container}>
<div className={styles.content}>
<section className={styles.settingsCategories}>
{categories.map((category, index) => (
<Button
key={category}
theme={
currentCategoryIndex === index ? "primary" : "outline"
}
onClick={() => setCurrentCategoryIndex(index)}
>
{category}
</Button>
))}
</section>
<h2>{categories[currentCategoryIndex]}</h2>
{renderCategory()}
</div>
</section>
);
}}
</SettingsContextConsumer>
</SettingsContextProvider>
);
}

View file

@ -0,0 +1,315 @@
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.totalPlayTimeInSeconds;
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", "webp"],
},
],
});
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: "5",
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,21 @@ 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;
totalPlayTimeInSeconds: number;
libraryGames: UserGame[];
recentGames: UserGame[];
}
export interface DownloadSource {
id: number;
name: string;