diff --git a/.cursorrules b/.cursorrules index 5015ab7e..fedb8a3a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -28,6 +28,26 @@ - Use async/await instead of promises when possible - Prefer named exports over default exports for utilities and services +## ESLint Issues + +- **Always try to fix ESLint errors properly before disabling rules** +- When encountering ESLint errors, explore these solutions in order: + 1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues) + 2. **Use minimal markup to satisfy the rule** (e.g., add empty `` elements for videos without captions, add `role` attributes) + 3. **Only disable the rule as a last resort** when no reasonable solution exists +- When disabling a rule, always include a comment explaining why it's necessary +- Examples of proper fixes: + - For `jsx-a11y/media-has-caption`: Add `` even if no captions are available + - For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images + - For accessibility rules: Add appropriate ARIA attributes rather than disabling + +## TypeScript Array Syntax + +- **Always use `T[]` syntax instead of `Array`** for array types +- Prefer: `string[]`, `number[]`, `MyType[]` +- Avoid: `Array`, `Array`, `Array` +- This applies to all type annotations, type assertions, and generic type parameters + ## Comments - Keep comments concise and purposeful; avoid verbose explanations. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index e9a91e0c..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Bug Report -description: Create a report to help us improve. Write in English. -title: "[BUG] Write a title for your bug" -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thank you for creating a bug report to help us improve! - - type: textarea - id: bug-description - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true - - type: textarea - id: bug-reproduce - attributes: - label: Steps to Reproduce - description: Steps to reproduce the behavior. For example, "1. Go to '...', 2. Click on '...', 3. See error" - validations: - required: true - - type: textarea - id: expected-behavior - attributes: - label: Expected behavior - description: A clear and concise description of what you expected to happen. - validations: - required: false - - type: textarea - id: additional-info - attributes: - label: Additional information and data - description: | - Add screenshots and upload your all logs file here. - Logs location on Windows: "%appdata%/hydralauncher/logs" - Logs location on Linux: "~/.config/hydralauncher/logs" - validations: - required: true - - type: input - id: OS - attributes: - label: Operating System - description: Which operating system are you using (e.g., Windows 11/Linux Distro/Steam Deck)? - validations: - required: true - - type: input - id: hydra-version - attributes: - label: Hydra Version - description: Please provide the version of Hydra you are using. - validations: - required: true - - type: checkboxes - id: terms - attributes: - label: Before opening this Issue - options: - - label: I have searched the issues of this repository and believe that this is not a duplicate. - required: true - - label: I am aware that Hydra team does not offer any support or help regarding the downloaded games. - required: true - - label: I have read the [Frequently Asked Questions (FAQ)](https://github.com/hydralauncher/hydra/wiki/FAQ). - required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 295cee45..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Feature Request -description: Request a new feature. -title: "[REQUEST] " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thank you for taking the time to suggest a new feature! - - type: textarea - id: problem-related - attributes: - label: Is your feature request related to a problem? Please describe. - description: A clear and concise description of what the problem is. - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe the solution you'd like - description: A clear and concise description of what you want to happen. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Describe alternatives you've considered - description: A clear and concise description of any alternative solutions or features you've considered. - validations: - required: false - - type: textarea - id: additional-context - attributes: - label: Additional context - description: Add any other context or screenshots about the feature request here. - validations: - required: false diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 3653dd16..22223374 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -2,11 +2,9 @@ **When submitting this pull request, I confirm the following (please check the boxes):** -- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute). +- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html). - [ ] I have checked that there are no duplicate pull requests related to this request. - [ ] I have considered, and confirm that this submission is valuable to others. - [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers. **Fill in the PR content:** - -- diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index fa12b500..22fcc49a 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -137,7 +137,7 @@ jobs: if git diff --staged --quiet; then echo "No changes to commit" else - COMMIT_MSG="v${{ steps.get-version.outputs.version }}" + COMMIT_MSG="${{ steps.get-version.outputs.version }}" git commit -m "$COMMIT_MSG" diff --git a/README.md b/README.md index 1cdc0f72..c086cb2e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-[](https://help.hydralauncher.gg) +[](https://help.hydralauncher.gg)

Hydra Launcher

diff --git a/package.json b/package.json index e2fec5ee..bb74198f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.4", + "version": "3.7.6", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -63,12 +63,14 @@ "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", + "hls.js": "^1.5.12", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", "jsdom": "^24.0.0", "jsonwebtoken": "^9.0.2", "lodash-es": "^4.17.21", "lucide-react": "^0.544.0", + "node-7z": "^3.0.0", "parse-torrent": "^11.0.18", "rc-virtual-list": "^3.18.3", "react-dnd": "^16.0.1", @@ -84,7 +86,7 @@ "sound-play": "^1.1.0", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "sudo-prompt": "^9.2.1", - "tar": "^7.4.3", + "tar": "^7.5.2", "tough-cookie": "^5.1.1", "user-agents": "^1.1.387", "uuid": "^13.0.0", diff --git a/python_rpc/main.py b/python_rpc/main.py index 36170025..99dd0d8c 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -153,8 +153,11 @@ def profile_image(): data = request.get_json() image_path = data.get('image_path') + # use webp as default value for target_extension + target_extension = data.get('target_extension') or 'webp' + try: - processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path) + processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path, target_extension) return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200 except Exception as e: return jsonify({"error": str(e)}), 400 diff --git a/python_rpc/profile_image_processor.py b/python_rpc/profile_image_processor.py index 45ba5160..eac8c32a 100644 --- a/python_rpc/profile_image_processor.py +++ b/python_rpc/profile_image_processor.py @@ -4,7 +4,7 @@ import os, uuid, tempfile class ProfileImageProcessor: @staticmethod - def get_parsed_image_data(image_path): + def get_parsed_image_data(image_path, target_extension): Image.MAX_IMAGE_PIXELS = 933120000 image = Image.open(image_path) @@ -16,7 +16,7 @@ class ProfileImageProcessor: return image_path, mime_type else: new_uuid = str(uuid.uuid4()) - new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp" + new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension image.save(new_image_path) new_image = Image.open(new_image_path) @@ -26,5 +26,5 @@ class ProfileImageProcessor: @staticmethod - def process_image(image_path): - return ProfileImageProcessor.get_parsed_image_data(image_path) + def process_image(image_path, target_extension): + return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5084a4a0..9be4ff26 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -94,6 +94,12 @@ "header": { "search": "Search games", "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear", + "remove_from_history": "Remove from history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", "library": "Library", @@ -109,6 +115,7 @@ "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", "calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…", "checking_files": "Checking {{title}} files… ({{percentage}} complete)", + "extracting": "Extracting {{title}}… ({{percentage}} complete)", "installing_common_redist": "{{log}}…", "installation_complete": "Installation complete", "installation_complete_message": "Common redistributables installed successfully" @@ -196,6 +203,7 @@ "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", + "extracting": "Extracting", "last_downloaded_option": "Last downloaded option", "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", @@ -408,7 +416,11 @@ "resume_seeding": "Resume seeding", "options": "Manage", "extract": "Extract files", - "extracting": "Extracting files…" + "extracting": "Extracting files…", + "delete_archive_title": "Would you like to delete {{fileName}}?", + "delete_archive_description": "The file has been successfully extracted and it's no longer needed.", + "yes": "Yes", + "no": "No" }, "settings": { "downloads_path": "Downloads path", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5a65d3cf..12dae377 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -93,8 +93,16 @@ }, "header": { "search": "Buscar juegos", + "search_library": "Buscar en la librería", + "recent_searches": "Búsquedas Recientes", + "suggestions": "Sugerencias", + "clear_history": "Limpiar", + "remove_from_history": "Eliminar del historial", + "loading": "Cargando...", + "no_results": "Sin resultados", "home": "Inicio", "catalogue": "Catálogo", + "library": "Librería", "downloads": "Descargas", "search_results": "Resultados de búsqueda", "settings": "Ajustes", @@ -450,6 +458,7 @@ "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", "button_delete_all_sources": "Eliminar todo", "added_download_source": "Añadir fuente de descarga", + "adding": "Añadiendo…", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas", "insert_valid_json_url": "Introducí una URL de json válida", "found_download_option_zero": "Sin opciones de descargas encontrada", @@ -555,6 +564,19 @@ "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", + "change_achievement_sound": "Cambiar sonido de logro", + "download_source_already_exists": "Esta fuente de descarga URL ya existe.", + "download_source_failed": "Error", + "download_source_matched": "Actualizado", + "download_source_matching": "Actualizando", + "download_source_no_information": "Sin información disponible", + "download_source_pending_matching": "Actualizando pronto", + "download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas", + "failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.", + "hydra_cloud": "Hydra Cloud", + "preview_sound": "Vista previa de sonido", + "remove_achievement_sound": "Eliminar sonido de logros", + "removed_all_download_sources": "Todas las fuentes de descarga eliminadas", "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego" }, "notifications": { diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 73d5e8fb..ee0da176 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -93,11 +93,19 @@ }, "header": { "search": "Buscar jogos", + "search_library": "Buscar na biblioteca", + "recent_searches": "Buscas Recentes", + "suggestions": "Sugestões", + "clear_history": "Limpar", + "remove_from_history": "Remover do histórico", + "loading": "Carregando...", + "no_results": "Sem resultados", + "home": "Início", "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Downloads", "search_results": "Resultados da busca", "settings": "Ajustes", - "home": "Início", "version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download." }, @@ -107,6 +115,7 @@ "downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", "calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…", "checking_files": "Verificando arquivos de {{title}}…", + "extracting": "Extraindo {{title}}… ({{percentage}} concluído)", "installing_common_redist": "{{log}}…", "installation_complete": "Instalação concluída", "installation_complete_message": "Componentes recomendados instalados com sucesso" @@ -182,6 +191,7 @@ "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", + "extracting": "Extraindo", "last_downloaded_option": "Última opção baixada", "new_download_option": "Novo", "create_steam_shortcut": "Criar atalho na Steam", @@ -394,7 +404,11 @@ "resume_seeding": "Semear", "options": "Gerenciar", "extract": "Extrair arquivos", - "extracting": "Extraindo arquivos…" + "extracting": "Extraindo arquivos…", + "delete_archive_title": "Deseja deletar {{fileName}}?", + "delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.", + "yes": "Sim", + "no": "Não" }, "settings": { "downloads_path": "Diretório dos downloads", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index c8e4586d..e48e1458 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -30,11 +30,19 @@ }, "header": { "search": "Procurar jogos", + "search_library": "Procurar na biblioteca", + "recent_searches": "Pesquisas Recentes", + "suggestions": "Sugestões", + "clear_history": "Limpar", + "remove_from_history": "Remover do histórico", + "loading": "A carregar...", + "no_results": "Sem resultados", + "home": "Início", "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Transferências", "search_results": "Resultados da pesquisa", "settings": "Definições", - "home": "Início", "version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.", "version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download." }, diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b831ff2e..1cf7ae2f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -93,8 +93,16 @@ }, "header": { "search": "Поиск", + "search_library": "Поиск в библиотеке", + "recent_searches": "Недавние поиски", + "suggestions": "Предложения", + "clear_history": "Очистить", + "remove_from_history": "Удалить из истории", + "loading": "Загрузка...", + "no_results": "Нет результатов", "home": "Главная", "catalogue": "Каталог", + "library": "Библиотека", "downloads": "Загрузки", "search_results": "Результаты поиска", "settings": "Настройки", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index e8e1cb2b..52e1f10f 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -16,6 +16,7 @@ "downloads": "İndirilenler", "settings": "Ayarlar", "my_library": "Kütüphanem", + "library": "Kütüphane", "downloading_metadata": "{{title}} (Meta verileri indiriliyor…)", "paused": "{{title}} (Duraklatıldı)", "downloading": "{{title}} (%{{percentage}} - İndiriliyor…)", @@ -26,7 +27,69 @@ "sign_in": "Giriş Yap", "friends": "Arkadaşlar", "need_help": "Yardıma mı ihtiyacınız var?", - "favorites": "Favoriler" + "favorites": "Favoriler", + "playable_button_title": "Şu anda oynayabileceğin oyunları göster", + "add_custom_game_tooltip": "Özel Oyun Ekle", + "show_playable_only_tooltip": "Sadece Oynanabilirleri Göster", + "custom_game_modal": "Özel Oyun Ekle", + "custom_game_modal_description": "Çalıştırılabilir bir dosya seçerek kütüphanene özel oyun ekle", + "custom_game_modal_executable_path": "Çalıştırılabilir Dosya Yolu", + "custom_game_modal_select_executable": "Çalıştırılabilir dosya seç", + "custom_game_modal_title": "Başlık", + "custom_game_modal_enter_title": "Başlık gir", + "custom_game_modal_browse": "Gözat", + "custom_game_modal_cancel": "İptal", + "custom_game_modal_add": "Oyun Ekle", + "custom_game_modal_adding": "Oyun Ekleniyor...", + "custom_game_modal_success": "Özel oyun başarıyla eklendi", + "custom_game_modal_failed": "Özel oyun eklenemedi", + "custom_game_modal_executable": "Çalıştırılabilir", + "edit_game_modal": "Varlıkları Özelleştir", + "edit_game_modal_description": "Oyun varlıklarını ve detaylarını özelleştir", + "edit_game_modal_title": "Başlık", + "edit_game_modal_enter_title": "Başlık gir", + "edit_game_modal_image": "Görsel", + "edit_game_modal_select_image": "Görsel seç", + "edit_game_modal_browse": "Gözat", + "edit_game_modal_image_preview": "Görsel önizleme", + "edit_game_modal_icon": "İkon", + "edit_game_modal_select_icon": "İkon seç", + "edit_game_modal_icon_preview": "İkon önizleme", + "edit_game_modal_logo": "Logo", + "edit_game_modal_select_logo": "Logo seç", + "edit_game_modal_logo_preview": "Logo önizleme", + "edit_game_modal_hero": "Kütüphane Hero", + "edit_game_modal_select_hero": "Kütüphane hero görseli seç", + "edit_game_modal_hero_preview": "Kütüphane hero görseli önizleme", + "edit_game_modal_cancel": "İptal et", + "edit_game_modal_update": "Güncelle", + "edit_game_modal_updating": "Güncelleniyor...", + "edit_game_modal_fill_required": "Lütfen tüm gerekli alanları doldur", + "edit_game_modal_success": "Varlıklar başarıyla güncellendi", + "edit_game_modal_failed": "Varlıklar güncellenemedi", + "edit_game_modal_image_filter": "Görsel", + "edit_game_modal_icon_resolution": "Önerilen çözünürlük: 256x256px", + "edit_game_modal_logo_resolution": "Önerilen çözünürlük: 640x360px", + "edit_game_modal_hero_resolution": "Önerilen çözünürlük: 1920x620px", + "edit_game_modal_assets": "Varlıklar", + "edit_game_modal_drop_icon_image_here": "İkon görselini buraya bırak", + "edit_game_modal_drop_logo_image_here": "Logo görselini buraya bırak", + "edit_game_modal_drop_hero_image_here": "Hero görselini buraya bırak", + "edit_game_modal_drop_to_replace_icon": "İkonu değiştirmek için buraya bırak", + "edit_game_modal_drop_to_replace_logo": "Logoyu değiştirmek için buraya bırak", + "edit_game_modal_drop_to_replace_hero": "Hero'yu değiştirmek için buraya bırak", + "install_decky_plugin": "Decky Plugin Kur", + "update_decky_plugin": "Decky Plugin Güncelle", + "decky_plugin_installed_version": "Decky Plugin (v{{version}})", + "install_decky_plugin_title": "Hydra Decky Plugin Kur", + "install_decky_plugin_message": "Bu işlem Decky Loader için Hydra plugin'ini indirecek ve kuracak. Bu işlem yükseltilmiş izinler gerektirebilir. Devam et?", + "update_decky_plugin_title": "Hydra Decky Plugin Güncelle", + "update_decky_plugin_message": "Hydra Decky plugin'inin yeni bir sürümü mevcut. Şimdi güncellemek ister misin?", + "decky_plugin_installed": "Decky plugin v{{version}} başarıyla kuruldu", + "decky_plugin_installation_failed": "Decky plugin kurulamadı: {{error}}", + "decky_plugin_installation_error": "Decky plugin kurulumu hatası: {{error}}", + "confirm": "Onayla", + "cancel": "İptal" }, "header": { "search": "Oyunlarda Ara", @@ -35,6 +98,8 @@ "downloads": "İndirilenler", "search_results": "Arama Sonuçları", "settings": "Ayarlar", + "search_library": "Kütüphanede ara", + "library": "Kütüphane", "version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.", "version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın." }, @@ -203,7 +268,108 @@ "create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur", "invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu", "invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.", - "missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir" + "missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir", + "already_in_library": "Zaten kütüphanede", + "create_shortcut_simple": "Kısayol oluştur", + "properties": "Özellikler", + "new_download_option": "Yeni", + "add_to_favorites": "Favorilere ekle", + "remove_from_favorites": "Favorilerden çıkar", + "failed_update_favorites": "Favoriler güncellenemedi", + "game_removed_from_library": "Oyun kütüphaneden çıkarıldı", + "failed_remove_from_library": "Kütüphaneden çıkarılamadı", + "files_removed_success": "Dosyalar başarıyla kaldırıldı", + "failed_remove_files": "Dosyalar kaldırılamadı", + "rating_count": "Puan", + "show_more": "Daha fazla göster", + "show_less": "Daha az göster", + "reviews": "İncelemeler", + "review_played_for": "Oynama süresi", + "leave_a_review": "İnceleme Yap", + "write_review_placeholder": "Bu oyun hakkındaki düşüncelerini paylaş...", + "sort_newest": "En yeni", + "no_reviews_yet": "Henüz inceleme yok", + "be_first_to_review": "Bu oyun hakkındaki düşüncelerini paylaşan ilk kişi ol!", + "sort_oldest": "En eski", + "sort_highest_score": "En yüksek puan", + "sort_lowest_score": "En düşük puan", + "sort_most_voted": "En çok oy", + "rating": "Puan", + "rating_stats": "Puan", + "rating_very_negative": "Çok Olumsuz", + "rating_negative": "Olumsuz", + "rating_neutral": "Nötr", + "rating_positive": "Olumlu", + "rating_very_positive": "Çok Olumlu", + "submit_review": "Gönder", + "submitting": "Gönderiliyor...", + "review_submitted_successfully": "İnceleme başarıyla gönderildi!", + "review_submission_failed": "İnceleme gönderilemedi. Lütfen tekrar dene.", + "review_cannot_be_empty": "İnceleme metin alanı boş olamaz.", + "review_deleted_successfully": "İnceleme başarıyla silindi.", + "review_deletion_failed": "İnceleme silinemedi. Lütfen tekrar dene.", + "loading_reviews": "İncelemeler yükleniyor...", + "loading_more_reviews": "Daha fazla inceleme yükleniyor...", + "load_more_reviews": "Daha fazla inceleme yükle", + "you_seemed_to_enjoy_this_game": "Bu oyunu beğenmiş görünüyorsun", + "would_you_recommend_this_game": "Bu oyun hakkında bir inceleme yazmak ister misin?", + "yes": "Evet", + "maybe_later": "Belki sonra", + "backup_failed": "Yedekleme başarısız", + "update_playtime_title": "Oynama süresini güncelle", + "update_playtime_description": "{{game}} için oynama süresini manuel olarak güncelle", + "update_playtime": "Oynama süresini güncelle", + "update_playtime_success": "Oynama süresi başarıyla güncellendi", + "update_playtime_error": "Oynama süresi güncellenemedi", + "update_game_playtime": "Oyun oynama süresini güncelle", + "manual_playtime_warning": "Saatlerin manuel olarak güncellendiği işaretlenecek ve bu geri alınamaz.", + "manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi", + "game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı", + "game_added_to_pinned": "Oyun sabitlenmişlere eklendi", + "artifact_renamed": "Yedekleme başarıyla yeniden adlandırıldı", + "rename_artifact": "Yedeklemeyi Yeniden Adlandır", + "rename_artifact_description": "Yedeklemeyi daha açıklayıcı bir isimle yeniden adlandır", + "artifact_name_label": "Yedekleme adı", + "artifact_name_placeholder": "Yedekleme için bir isim gir", + "save_changes": "Değişiklikleri kaydet", + "required_field": "Bu alan gereklidir", + "max_length_field": "Bu alan {{length}} karakterden az olmalıdır", + "freeze_backup": "Otomatik yedeklemeler tarafından üzerine yazılmasın diye sabitle", + "unfreeze_backup": "Sabitlemeyi kaldır", + "backup_frozen": "Yedekleme sabitlendi", + "backup_unfrozen": "Yedekleme sabitlemesi kaldırıldı", + "backup_freeze_failed": "Yedekleme sabitlenemedi", + "backup_freeze_failed_description": "Otomatik yedeklemeler için en az bir boş alan bırakmalısın", + "edit_game_modal_button": "Oyun varlıklarını özelleştir", + "game_details": "Oyun Detayları", + "currency_symbol": "₺", + "currency_country": "tr", + "prices": "Fiyatlar", + "no_prices_found": "Fiyat bulunamadı", + "view_all_prices": "Tüm fiyatları görüntülemek için tıkla", + "retail_price": "Perakende fiyatı", + "keyshop_price": "Anahtar dükkanı fiyatı", + "historical_retail": "Geçmiş perakende", + "historical_keyshop": "Geçmiş anahtar dükkanı", + "language": "Dil", + "caption": "Altyazı", + "audio": "Ses", + "filter_by_source": "Kaynağa göre filtrele", + "no_repacks_found": "Bu oyun için kaynak bulunamadı", + "delete_review": "İncelemeyi sil", + "remove_review": "İncelemeyi Kaldır", + "delete_review_modal_title": "İncelemeni silmek istediğinden emin misin?", + "delete_review_modal_description": "Bu işlem geri alınamaz.", + "delete_review_modal_delete_button": "Sil", + "delete_review_modal_cancel_button": "İptal", + "vote_failed": "Oyun kaydı başarısız oldu. Lütfen tekrar dene.", + "show_original": "Orijinali göster", + "show_translation": "Çeviriyi göster", + "show_original_translated_from": "Orijinali göster ({{language}} dilinden çevrilmiştir)", + "hide_original": "Orijinali gizle", + "review_from_blocked_user": "Engellenen kullanıcıdan gelen inceleme", + "show": "Göster", + "hide": "Gizle" }, "activation": { "title": "Hydra'yı Etkinleştir", @@ -379,7 +545,33 @@ "hidden": "Gizli", "test_notification": "Test bildirimi", "notification_preview": "Başarı Bildirimi Önizlemesi", - "enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında" + "enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında", + "adding": "Ekleniyor…", + "failed_add_download_source": "İndirme kaynağı eklenemedi. Lütfen tekrar dene.", + "download_source_already_exists": "Bu indirme kaynağı URL'si zaten mevcut.", + "download_source_pending_matching": "Yakında güncellenecek", + "download_source_matched": "Güncel", + "download_source_matching": "Güncelleniyor", + "download_source_failed": "Hata", + "download_source_no_information": "Bilgi mevcut değil", + "removed_all_download_sources": "Tüm indirme kaynakları kaldırıldı", + "download_sources_synced_successfully": "Tüm indirme kaynakları senkronize edildi", + "importing": "İçe aktarılıyor...", + "hydra_cloud": "Hydra Cloud", + "debrid": "Debrid", + "debrid_description": "Debrid servisleri, internet hızınızla sınırlı, çeşitli dosya barındırma hizmetlerinde barındırılan dosyaları hızla indirmenize olanak tanıyan premium sınırsız indiricilerdir.", + "enable_steam_achievements": "Steam başarımları aramasını etkinleştir", + "achievement_sound_volume": "Başarım ses seviyesi", + "select_achievement_sound": "Başarım sesi seç", + "change_achievement_sound": "Başarım sesini değiştir", + "remove_achievement_sound": "Başarım sesini kaldır", + "preview_sound": "Sesi önizle", + "select": "Seç", + "preview": "Önizle", + "remove": "Kaldır", + "no_sound_file_selected": "Ses dosyası seçilmedi", + "autoplay_trailers_on_game_page": "Oyun sayfasında fragmanları otomatik olarak oynat", + "hide_to_tray_on_game_start": "Oyun başlatıldığında Hydra'yı sistem tepsisine gizle" }, "notifications": { "download_complete": "İndirme tamamlandı", @@ -406,7 +598,8 @@ "game_card": { "available_one": "Mevcut", "available_other": "Mevcut", - "no_downloads": "İndirme mevcut değil" + "no_downloads": "İndirme mevcut değil", + "calculating": "Hesaplanıyor" }, "binary_not_found_modal": { "title": "Programlar Yüklü Değil", @@ -498,7 +691,46 @@ "achievements_unlocked": "Açılan başarımlar", "earned_points": "Kazanılan puanlar", "show_achievements_on_profile": "Başarımlarını profilinde göster", - "show_points_on_profile": "Kazanılan puanlarını profilinde göster" + "show_points_on_profile": "Kazanılan puanlarını profilinde göster", + "amount_hours_short": "{{amount}}s", + "amount_minutes_short": "{{amount}}d", + "pinned": "Sabitlenmiş", + "sort_by": "Sırala:", + "achievements_earned": "Kazanılan başarımlar", + "played_recently": "Son oynanan", + "playtime": "Oynama süresi", + "manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi", + "error_adding_friend": "Arkadaş isteği gönderilemedi. Lütfen arkadaş kodunu kontrol et", + "friend_code_length_error": "Arkadaş kodu 8 karakter olmalıdır", + "game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı", + "game_added_to_pinned": "Oyun sabitlenmişlere eklendi", + "karma": "Karma", + "karma_count": "karma", + "karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır", + "user_reviews": "İncelemeler", + "delete_review": "İncelemeyi Sil", + "loading_reviews": "İncelemeler yükleniyor..." + }, + "library": { + "library": "Kütüphane", + "play": "Oyna", + "download": "İndir", + "downloading": "İndiriliyor", + "game": "oyun", + "games": "oyunlar", + "grid_view": "Izgara görünümü", + "compact_view": "Kompakt görünüm", + "large_view": "Büyük görünüm", + "no_games_title": "Kütüphanen boş", + "no_games_description": "Başlamak için katalogdan oyun ekle veya indir", + "amount_hours": "{{amount}} saat", + "amount_minutes": "{{amount}} dakika", + "amount_hours_short": "{{amount}}s", + "amount_minutes_short": "{{amount}}d", + "manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi", + "all_games": "Tüm Oyunlar", + "recently_played": "Son Oynanan", + "favorites": "Favoriler" }, "achievement": { "achievement_unlocked": "Başarım açıldı", diff --git a/src/main/events/auth/index.ts b/src/main/events/auth/index.ts new file mode 100644 index 00000000..e94e9bc5 --- /dev/null +++ b/src/main/events/auth/index.ts @@ -0,0 +1,3 @@ +import "./get-session-hash"; +import "./open-auth-window"; +import "./sign-out"; diff --git a/src/main/events/autoupdater/index.ts b/src/main/events/autoupdater/index.ts new file mode 100644 index 00000000..f6b70367 --- /dev/null +++ b/src/main/events/autoupdater/index.ts @@ -0,0 +1,2 @@ +import "./check-for-updates"; +import "./restart-and-install-update"; diff --git a/src/main/events/catalogue/index.ts b/src/main/events/catalogue/index.ts new file mode 100644 index 00000000..383ba34c --- /dev/null +++ b/src/main/events/catalogue/index.ts @@ -0,0 +1,4 @@ +import "./get-game-assets"; +import "./get-game-shop-details"; +import "./get-game-stats"; +import "./get-random-game"; diff --git a/src/main/events/cloud-save/index.ts b/src/main/events/cloud-save/index.ts new file mode 100644 index 00000000..92e9f528 --- /dev/null +++ b/src/main/events/cloud-save/index.ts @@ -0,0 +1,4 @@ +import "./download-game-artifact"; +import "./get-game-backup-preview"; +import "./select-game-backup-path"; +import "./upload-save-game"; diff --git a/src/main/events/download-sources/index.ts b/src/main/events/download-sources/index.ts new file mode 100644 index 00000000..325d5570 --- /dev/null +++ b/src/main/events/download-sources/index.ts @@ -0,0 +1,6 @@ +import "./add-download-source"; +import "./get-download-sources-check-baseline"; +import "./get-download-sources-since-value"; +import "./get-download-sources"; +import "./remove-download-source"; +import "./sync-download-sources"; diff --git a/src/main/events/hardware/index.ts b/src/main/events/hardware/index.ts new file mode 100644 index 00000000..76823f51 --- /dev/null +++ b/src/main/events/hardware/index.ts @@ -0,0 +1,2 @@ +import "./check-folder-write-permission"; +import "./get-disk-free-space"; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 2720d3ce..8efadf64 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,107 +1,22 @@ import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { ipcMain } from "electron"; -import "./catalogue/get-game-shop-details"; -import "./catalogue/get-random-game"; -import "./catalogue/get-game-stats"; -import "./hardware/get-disk-free-space"; -import "./hardware/check-folder-write-permission"; -import "./library/add-game-to-library"; -import "./library/add-custom-game-to-library"; -import "./library/update-custom-game"; -import "./library/update-game-custom-assets"; -import "./library/add-game-to-favorites"; -import "./library/remove-game-from-favorites"; -import "./library/toggle-game-pin"; -import "./library/create-game-shortcut"; -import "./library/close-game"; -import "./library/delete-game-folder"; -import "./library/get-game-by-object-id"; -import "./library/get-library"; -import "./library/refresh-library-assets"; -import "./library/extract-game-download"; -import "./library/clear-new-download-options"; -import "./library/open-game"; -import "./library/open-game-executable-path"; -import "./library/open-game-installer"; -import "./library/open-game-installer-path"; -import "./library/update-executable-path"; -import "./library/update-launch-options"; -import "./library/verify-executable-path"; -import "./library/remove-game"; -import "./library/remove-game-from-library"; -import "./library/select-game-wine-prefix"; -import "./library/reset-game-achievements"; -import "./library/change-game-playtime"; -import "./library/toggle-automatic-cloud-sync"; -import "./library/get-default-wine-prefix-selection-path"; -import "./library/cleanup-unused-assets"; -import "./library/create-steam-shortcut"; -import "./library/copy-custom-game-asset"; -import "./misc/open-checkout"; -import "./misc/open-external"; -import "./misc/show-open-dialog"; -import "./misc/show-item-in-folder"; -import "./misc/install-common-redist"; -import "./misc/can-install-common-redist"; -import "./misc/save-temp-file"; -import "./misc/delete-temp-file"; -import "./misc/install-hydra-decky-plugin"; -import "./misc/get-hydra-decky-plugin-info"; -import "./misc/check-homebrew-folder-exists"; -import "./misc/hydra-api-call"; -import "./torrenting/cancel-game-download"; -import "./torrenting/pause-game-download"; -import "./torrenting/resume-game-download"; -import "./torrenting/start-game-download"; -import "./torrenting/pause-game-seed"; -import "./torrenting/resume-game-seed"; -import "./torrenting/check-debrid-availability"; -import "./user-preferences/get-user-preferences"; -import "./user-preferences/update-user-preferences"; -import "./user-preferences/auto-launch"; -import "./autoupdater/check-for-updates"; -import "./autoupdater/restart-and-install-update"; -import "./user-preferences/authenticate-real-debrid"; -import "./user-preferences/authenticate-torbox"; -import "./download-sources/add-download-source"; -import "./download-sources/sync-download-sources"; -import "./download-sources/get-download-sources-check-baseline"; -import "./download-sources/get-download-sources-since-value"; -import "./auth/sign-out"; -import "./auth/open-auth-window"; -import "./auth/get-session-hash"; -import "./user/get-auth"; -import "./user/get-unlocked-achievements"; -import "./user/get-compared-unlocked-achievements"; -import "./profile/get-me"; -import "./profile/update-profile"; -import "./profile/process-profile-image"; -import "./profile/sync-friend-requests"; -import "./cloud-save/download-game-artifact"; -import "./cloud-save/get-game-backup-preview"; -import "./cloud-save/upload-save-game"; -import "./cloud-save/select-game-backup-path"; -import "./notifications/publish-new-repacks-notification"; -import "./notifications/update-achievement-notification-window"; -import "./notifications/show-achievement-test-notification"; -import "./themes/add-custom-theme"; -import "./themes/delete-custom-theme"; -import "./themes/get-all-custom-themes"; -import "./themes/delete-all-custom-themes"; -import "./themes/update-custom-theme"; -import "./themes/open-editor-window"; -import "./themes/get-custom-theme-by-id"; -import "./themes/get-active-custom-theme"; -import "./themes/close-editor-window"; -import "./themes/toggle-custom-theme"; -import "./themes/copy-theme-achievement-sound"; -import "./themes/remove-theme-achievement-sound"; -import "./themes/get-theme-sound-path"; -import "./themes/get-theme-sound-data-url"; -import "./themes/import-theme-sound-from-store"; -import "./download-sources/remove-download-source"; -import "./download-sources/get-download-sources"; +import "./auth"; +import "./autoupdater"; +import "./catalogue"; +import "./cloud-save"; +import "./download-sources"; +import "./hardware"; +import "./library"; +import "./leveldb"; +import "./misc"; +import "./notifications"; +import "./profile"; +import "./themes"; +import "./torrenting"; +import "./user"; +import "./user-preferences"; + import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/leveldb/helpers.ts b/src/main/events/leveldb/helpers.ts new file mode 100644 index 00000000..e171e65a --- /dev/null +++ b/src/main/events/leveldb/helpers.ts @@ -0,0 +1,27 @@ +import { db } from "@main/level"; + +const sublevelCache = new Map< + string, + ReturnType> +>(); + +/** + * Gets a sublevel by name, creating it if it doesn't exist. + * All sublevels use "json" encoding by default. + * @param sublevelName - The name of the sublevel to get or create + * @returns The sublevel instance + */ +export const getSublevelByName = ( + sublevelName: string +): ReturnType> => { + if (sublevelCache.has(sublevelName)) { + return sublevelCache.get(sublevelName)!; + } + + // All sublevels use "json" encoding - this cannot be changed per sublevel + const sublevel = db.sublevel(sublevelName, { + valueEncoding: "json", + }); + sublevelCache.set(sublevelName, sublevel); + return sublevel; +}; diff --git a/src/main/events/leveldb/index.ts b/src/main/events/leveldb/index.ts new file mode 100644 index 00000000..6007bd33 --- /dev/null +++ b/src/main/events/leveldb/index.ts @@ -0,0 +1,6 @@ +import "./leveldb-get"; +import "./leveldb-put"; +import "./leveldb-del"; +import "./leveldb-clear"; +import "./leveldb-values"; +import "./leveldb-iterator"; diff --git a/src/main/events/leveldb/leveldb-clear.ts b/src/main/events/leveldb/leveldb-clear.ts new file mode 100644 index 00000000..cbed1db0 --- /dev/null +++ b/src/main/events/leveldb/leveldb-clear.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbClear = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + await sublevel.clear(); + } catch (error) { + logger.error("Error in leveldbClear", error); + throw error; + } +}; + +registerEvent("leveldbClear", leveldbClear); diff --git a/src/main/events/leveldb/leveldb-del.ts b/src/main/events/leveldb/leveldb-del.ts new file mode 100644 index 00000000..5bcded1d --- /dev/null +++ b/src/main/events/leveldb/leveldb-del.ts @@ -0,0 +1,28 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbDel = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + sublevelName?: string | null +) => { + try { + if (sublevelName) { + const sublevel = getSublevelByName(sublevelName); + await sublevel.del(key); + } else { + await db.del(key); + } + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + // NotFoundError on delete is not an error, just return + return; + } + logger.error("Error in leveldbDel", error); + throw error; + } +}; + +registerEvent("leveldbDel", leveldbDel); diff --git a/src/main/events/leveldb/leveldb-get.ts b/src/main/events/leveldb/leveldb-get.ts new file mode 100644 index 00000000..059f1b30 --- /dev/null +++ b/src/main/events/leveldb/leveldb-get.ts @@ -0,0 +1,28 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbGet = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + sublevelName?: string | null, + valueEncoding: "json" | "utf8" = "json" +) => { + try { + if (sublevelName) { + // Note: sublevels always use "json" encoding, valueEncoding parameter is ignored + const sublevel = getSublevelByName(sublevelName); + return sublevel.get(key); + } + return db.get(key, { valueEncoding }); + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + return null; + } + logger.error("Error in leveldbGet", error); + throw error; + } +}; + +registerEvent("leveldbGet", leveldbGet); diff --git a/src/main/events/leveldb/leveldb-iterator.ts b/src/main/events/leveldb/leveldb-iterator.ts new file mode 100644 index 00000000..a1960c31 --- /dev/null +++ b/src/main/events/leveldb/leveldb-iterator.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbIterator = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + return sublevel.iterator().all(); + } catch (error) { + logger.error("Error in leveldbIterator", error); + throw error; + } +}; + +registerEvent("leveldbIterator", leveldbIterator); diff --git a/src/main/events/leveldb/leveldb-put.ts b/src/main/events/leveldb/leveldb-put.ts new file mode 100644 index 00000000..9c416722 --- /dev/null +++ b/src/main/events/leveldb/leveldb-put.ts @@ -0,0 +1,27 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbPut = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding: "json" | "utf8" = "json" +) => { + try { + if (sublevelName) { + // Note: sublevels always use "json" encoding, valueEncoding parameter is ignored + const sublevel = getSublevelByName(sublevelName); + await sublevel.put(key, value); + } else { + await db.put(key, value, { valueEncoding }); + } + } catch (error) { + logger.error("Error in leveldbPut", error); + throw error; + } +}; + +registerEvent("leveldbPut", leveldbPut); diff --git a/src/main/events/leveldb/leveldb-values.ts b/src/main/events/leveldb/leveldb-values.ts new file mode 100644 index 00000000..0e2c3c0f --- /dev/null +++ b/src/main/events/leveldb/leveldb-values.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbValues = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + return sublevel.values().all(); + } catch (error) { + logger.error("Error in leveldbValues", error); + throw error; + } +}; + +registerEvent("leveldbValues", leveldbValues); diff --git a/src/main/events/library/delete-archive.ts b/src/main/events/library/delete-archive.ts new file mode 100644 index 00000000..9cf64a63 --- /dev/null +++ b/src/main/events/library/delete-archive.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; + +import { registerEvent } from "../register-event"; +import { logger } from "@main/services"; + +const deleteArchive = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +) => { + try { + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + logger.info(`Deleted archive: ${filePath}`); + return true; + } + return true; + } catch (err) { + logger.error(`Failed to delete archive: ${filePath}`, err); + return false; + } +}; + +registerEvent("deleteArchive", deleteArchive); diff --git a/src/main/events/library/extract-game-download.ts b/src/main/events/library/extract-game-download.ts index 8fb24b81..b393e6b7 100644 --- a/src/main/events/library/extract-game-download.ts +++ b/src/main/events/library/extract-game-download.ts @@ -22,6 +22,7 @@ const extractGameDownload = async ( await downloadsSublevel.put(gameKey, { ...download, extracting: true, + extractionProgress: 0, }); const gameFilesManager = new GameFilesManager(shop, objectId); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index c434f6d3..9fb3416b 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -2,6 +2,7 @@ import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; import { downloadsSublevel, + gameAchievementsSublevel, gamesShopAssetsSublevel, gamesSublevel, } from "@main/level"; @@ -18,11 +19,20 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + let unlockedAchievementCount = game.unlockedAchievementCount ?? 0; + + if (!game.unlockedAchievementCount) { + const achievements = await gameAchievementsSublevel.get(key); + + unlockedAchievementCount = + achievements?.unlockedAchievements.length ?? 0; + } + return { id: key, ...game, download: download ?? null, - unlockedAchievementCount: game.unlockedAchievementCount ?? 0, + unlockedAchievementCount, achievementCount: game.achievementCount ?? 0, // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts new file mode 100644 index 00000000..75fc5cd9 --- /dev/null +++ b/src/main/events/library/index.ts @@ -0,0 +1,33 @@ +import "./add-custom-game-to-library"; +import "./add-game-to-favorites"; +import "./add-game-to-library"; +import "./change-game-playtime"; +import "./cleanup-unused-assets"; +import "./clear-new-download-options"; +import "./close-game"; +import "./copy-custom-game-asset"; +import "./create-game-shortcut"; +import "./create-steam-shortcut"; +import "./delete-archive"; +import "./delete-game-folder"; +import "./extract-game-download"; +import "./get-default-wine-prefix-selection-path"; +import "./get-game-by-object-id"; +import "./get-library"; +import "./open-game-executable-path"; +import "./open-game-installer-path"; +import "./open-game-installer"; +import "./open-game"; +import "./refresh-library-assets"; +import "./remove-game-from-favorites"; +import "./remove-game-from-library"; +import "./remove-game"; +import "./reset-game-achievements"; +import "./select-game-wine-prefix"; +import "./toggle-automatic-cloud-sync"; +import "./toggle-game-pin"; +import "./update-custom-game"; +import "./update-executable-path"; +import "./update-game-custom-assets"; +import "./update-launch-options"; +import "./verify-executable-path"; diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts new file mode 100644 index 00000000..354e6687 --- /dev/null +++ b/src/main/events/misc/index.ts @@ -0,0 +1,12 @@ +import "./can-install-common-redist"; +import "./check-homebrew-folder-exists"; +import "./delete-temp-file"; +import "./get-hydra-decky-plugin-info"; +import "./hydra-api-call"; +import "./install-common-redist"; +import "./install-hydra-decky-plugin"; +import "./open-checkout"; +import "./open-external"; +import "./save-temp-file"; +import "./show-item-in-folder"; +import "./show-open-dialog"; diff --git a/src/main/events/notifications/index.ts b/src/main/events/notifications/index.ts new file mode 100644 index 00000000..c6e681e8 --- /dev/null +++ b/src/main/events/notifications/index.ts @@ -0,0 +1,3 @@ +import "./publish-new-repacks-notification"; +import "./show-achievement-test-notification"; +import "./update-achievement-notification-window"; diff --git a/src/main/events/profile/index.ts b/src/main/events/profile/index.ts new file mode 100644 index 00000000..1548249f --- /dev/null +++ b/src/main/events/profile/index.ts @@ -0,0 +1,4 @@ +import "./get-me"; +import "./process-profile-image"; +import "./sync-friend-requests"; +import "./update-profile"; diff --git a/src/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts index 6166f7f8..bec17cb6 100644 --- a/src/main/events/profile/process-profile-image.ts +++ b/src/main/events/profile/process-profile-image.ts @@ -1,16 +1,20 @@ import { registerEvent } from "../register-event"; import { PythonRPC } from "@main/services/python-rpc"; -const processProfileImage = async ( +const processProfileImageEvent = async ( _event: Electron.IpcMainInvokeEvent, path: string ) => { + return processProfileImage(path, "webp"); +}; + +export const processProfileImage = async (path: string, extension?: string) => { return PythonRPC.rpc .post<{ imagePath: string; mimeType: string; - }>("/profile-image", { image_path: path }) + }>("/profile-image", { image_path: path, target_extension: extension }) .then((response) => response.data); }; -registerEvent("processProfileImage", processProfileImage); +registerEvent("processProfileImage", processProfileImageEvent); diff --git a/src/main/events/themes/index.ts b/src/main/events/themes/index.ts new file mode 100644 index 00000000..5f4d4a02 --- /dev/null +++ b/src/main/events/themes/index.ts @@ -0,0 +1,15 @@ +import "./add-custom-theme"; +import "./close-editor-window"; +import "./copy-theme-achievement-sound"; +import "./delete-all-custom-themes"; +import "./delete-custom-theme"; +import "./get-active-custom-theme"; +import "./get-all-custom-themes"; +import "./get-custom-theme-by-id"; +import "./get-theme-sound-data-url"; +import "./get-theme-sound-path"; +import "./import-theme-sound-from-store"; +import "./open-editor-window"; +import "./remove-theme-achievement-sound"; +import "./toggle-custom-theme"; +import "./update-custom-theme"; diff --git a/src/main/events/torrenting/index.ts b/src/main/events/torrenting/index.ts new file mode 100644 index 00000000..408ecf17 --- /dev/null +++ b/src/main/events/torrenting/index.ts @@ -0,0 +1,7 @@ +import "./cancel-game-download"; +import "./check-debrid-availability"; +import "./pause-game-download"; +import "./pause-game-seed"; +import "./resume-game-download"; +import "./resume-game-seed"; +import "./start-game-download"; diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index 48bb1c12..4525e2df 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -13,7 +13,11 @@ const resumeGameDownload = async ( const download = await downloadsSublevel.get(gameKey); - if (download?.status === "paused") { + if ( + download && + (download.status === "paused" || download.status === "active") && + download.progress !== 1 + ) { await DownloadManager.pauseDownload(); for await (const [key, value] of downloadsSublevel.iterator()) { diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 79d55ec3..4375698f 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -82,6 +82,7 @@ const startGameDownload = async ( queued: true, extracting: false, automaticallyExtract, + extractionProgress: 0, }; try { diff --git a/src/main/events/user-preferences/index.ts b/src/main/events/user-preferences/index.ts new file mode 100644 index 00000000..aab898e6 --- /dev/null +++ b/src/main/events/user-preferences/index.ts @@ -0,0 +1,5 @@ +import "./authenticate-real-debrid"; +import "./authenticate-torbox"; +import "./auto-launch"; +import "./get-user-preferences"; +import "./update-user-preferences"; diff --git a/src/main/events/user/index.ts b/src/main/events/user/index.ts new file mode 100644 index 00000000..cf63116f --- /dev/null +++ b/src/main/events/user/index.ts @@ -0,0 +1,3 @@ +import "./get-auth"; +import "./get-compared-unlocked-achievements"; +import "./get-unlocked-achievements"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index 4b60b962..36449b4d 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -7,7 +7,9 @@ export const getDownloadSourcesCheckBaseline = async (): Promise< string | null > => { try { - const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, { + valueEncoding: "utf8", + }); return timestamp; } catch (error) { if (error instanceof Error && error.name === "NotFoundError") { @@ -27,7 +29,9 @@ export const updateDownloadSourcesCheckBaseline = async ( timestamp: string ): Promise => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp, { + valueEncoding: "utf8", + }); }; // Gets the 'since' value the API used in the last check (for modal comparison) @@ -35,7 +39,9 @@ export const getDownloadSourcesSinceValue = async (): Promise< string | null > => { try { - const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, { + valueEncoding: "utf8", + }); return timestamp; } catch (error) { if (error instanceof Error && error.name === "NotFoundError") { @@ -55,5 +61,7 @@ export const updateDownloadSourcesSinceValue = async ( timestamp: string ): Promise => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, { + valueEncoding: "utf8", + }); }; diff --git a/src/main/main.ts b/src/main/main.ts index 1cadcebd..86bfb458 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,5 +1,5 @@ import { downloadsSublevel } from "./level/sublevels/downloads"; -import { sortBy } from "lodash-es"; +import { orderBy } from "lodash-es"; import { Downloader } from "@shared"; import { levelKeys, db } from "./level"; import type { UserPreferences } from "@types"; @@ -33,9 +33,7 @@ export const loadState = async () => { await import("./events"); - if (process.platform !== "darwin") { - Aria2.spawn(); - } + Aria2.spawn(); if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences.realDebridApiToken); @@ -68,7 +66,7 @@ export const loadState = async () => { .values() .all() .then((games) => { - return sortBy(games, "timestamp", "DESC"); + return orderBy(games, "timestamp", "desc"); }); downloads.forEach((download) => { diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts index 9a9f85be..0fa333dc 100644 --- a/src/main/services/7zip.ts +++ b/src/main/services/7zip.ts @@ -1,5 +1,5 @@ import { app } from "electron"; -import cp from "node:child_process"; +import Seven, { CommandLineSwitches } from "node-7z"; import path from "node:path"; import { logger } from "./logger"; @@ -9,6 +9,17 @@ export const binaryName = { win32: "7z.exe", }; +export interface ExtractionProgress { + percent: number; + fileCount: number; + file: string; +} + +export interface ExtractionResult { + success: boolean; + extractedFiles: string[]; +} + export class SevenZip { private static readonly binaryPath = app.isPackaged ? path.join(process.resourcesPath, binaryName[process.platform]) @@ -32,43 +43,109 @@ export class SevenZip { cwd?: string; passwords?: string[]; }, - successCb: () => void, - errorCb: () => void - ) { - const tryPassword = (index = -1) => { - const password = passwords[index] ?? ""; - logger.info(`Trying password ${password} on ${filePath}`); + onProgress?: (progress: ExtractionProgress) => void + ): Promise { + return new Promise((resolve, reject) => { + const tryPassword = (index = -1) => { + const password = passwords[index] ?? ""; + logger.info( + `Trying password "${password || "(empty)"}" on ${filePath}` + ); - const args = ["x", filePath, "-y", "-p" + password]; + const extractedFiles: string[] = []; + let fileCount = 0; - if (outputPath) { - args.push("-o" + outputPath); - } + const options: CommandLineSwitches = { + $bin: this.binaryPath, + $progress: true, + yes: true, + password: password || undefined, + }; - const child = cp.execFile(this.binaryPath, args, { - cwd, - }); - - child.once("exit", (code) => { - if (code === 0) { - successCb(); - return; + if (outputPath) { + options.outputDir = outputPath; } - if (index < passwords.length - 1) { + const stream = Seven.extractFull(filePath, outputPath || cwd || ".", { + ...options, + $spawnOptions: cwd ? { cwd } : undefined, + }); + + stream.on("progress", (progress) => { + if (onProgress) { + onProgress({ + percent: progress.percent, + fileCount: fileCount, + file: progress.fileCount?.toString() || "", + }); + } + }); + + stream.on("data", (data) => { + if (data.file) { + extractedFiles.push(data.file); + fileCount++; + } + }); + + stream.on("end", () => { logger.info( - `Failed to extract file: ${filePath} with password: ${password}. Trying next password...` + `Successfully extracted ${filePath} (${extractedFiles.length} files)` ); + resolve({ + success: true, + extractedFiles, + }); + }); - tryPassword(index + 1); - } else { - logger.info(`Failed to extract file: ${filePath}`); + stream.on("error", (err) => { + logger.error(`Extraction error for ${filePath}:`, err); - errorCb(); + if (index < passwords.length - 1) { + logger.info( + `Failed to extract file: ${filePath} with password: "${password}". Trying next password...` + ); + tryPassword(index + 1); + } else { + logger.error( + `Failed to extract file: ${filePath} after trying all passwords` + ); + reject(new Error(`Failed to extract file: ${filePath}`)); + } + }); + }; + + tryPassword(); + }); + } + + public static listFiles( + filePath: string, + password?: string + ): Promise { + return new Promise((resolve, reject) => { + const files: string[] = []; + + const options: CommandLineSwitches = { + $bin: this.binaryPath, + password: password || undefined, + }; + + const stream = Seven.list(filePath, options); + + stream.on("data", (data) => { + if (data.file) { + files.push(data.file); } }); - }; - tryPassword(); + stream.on("end", () => { + resolve(files); + }); + + stream.on("error", (err) => { + reject(err); + }); + }); } } diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index f6835558..f3f49018 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -7,9 +7,12 @@ export class Aria2 { private static process: cp.ChildProcess | null = null; public static spawn() { - const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2c") - : path.join(__dirname, "..", "..", "binaries", "aria2c"); + const binaryPath = + process.platform === "darwin" + ? "aria2c" + : app.isPackaged + ? path.join(process.resourcesPath, "aria2c") + : path.join(__dirname, "..", "..", "binaries", "aria2c"); this.process = cp.spawn( binaryPath, diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts index 4dc1fdad..cb8999c3 100644 --- a/src/main/services/decky-plugin.ts +++ b/src/main/services/decky-plugin.ts @@ -74,21 +74,16 @@ export class DeckyPlugin { await fs.promises.mkdir(extractPath, { recursive: true }); - return new Promise((resolve, reject) => { - SevenZip.extractFile( - { - filePath: zipPath, - outputPath: extractPath, - }, - () => { - logger.log(`Plugin extracted to: ${extractPath}`); - resolve(extractPath); - }, - () => { - reject(new Error("Failed to extract plugin")); - } - ); - }); + try { + await SevenZip.extractFile({ + filePath: zipPath, + outputPath: extractPath, + }); + logger.log(`Plugin extracted to: ${extractPath}`); + return extractPath; + } catch { + throw new Error("Failed to extract plugin"); + } } private static needsSudo(): boolean { diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 4dcebbb0..c208fa32 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -20,7 +20,7 @@ import { RealDebridClient } from "./real-debrid"; import path from "path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; -import { sortBy } from "lodash-es"; +import { orderBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; import { GameFilesManager } from "../game-files-manager"; import { HydraDebridClient } from "./hydra-debrid"; @@ -126,21 +126,10 @@ export class DownloadManager { } ); - if (WindowManager.mainWindow && download) { - WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - WindowManager.mainWindow.webContents.send( - "on-download-progress", - JSON.parse( - JSON.stringify({ - ...status, - game, - }) - ) - ); - } - const shouldExtract = download.automaticallyExtract; + // Handle download completion BEFORE sending progress to renderer + // This ensures extraction starts and DB is updated before UI reacts if (progress === 1 && download) { publishDownloadCompleteNotification(game); @@ -154,6 +143,7 @@ export class DownloadManager { shouldSeed: true, queued: false, extracting: shouldExtract, + extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); } else { await downloadsSublevel.put(gameId, { @@ -162,12 +152,22 @@ export class DownloadManager { shouldSeed: false, queued: false, extracting: shouldExtract, + extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); this.cancelDownload(gameId); } if (shouldExtract) { + // Send initial extraction progress BEFORE download progress + // This ensures the UI shows extraction immediately + WindowManager.mainWindow?.webContents.send( + "on-extraction-progress", + game.shop, + game.objectId, + 0 + ); + const gameFilesManager = new GameFilesManager( game.shop, game.objectId @@ -194,10 +194,10 @@ export class DownloadManager { .values() .all() .then((games) => { - return sortBy( + return orderBy( games.filter((game) => game.status === "paused" && game.queued), "timestamp", - "DESC" + "desc" ); }); @@ -209,6 +209,18 @@ export class DownloadManager { this.downloadingGameId = null; } } + + // Send progress to renderer after completion handling + if (WindowManager.mainWindow && download) { + WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); + WindowManager.mainWindow.webContents.send( + "on-download-progress", + structuredClone({ + ...status, + game, + }) + ); + } } } diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 120b3e8f..f3684a0a 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -3,24 +3,58 @@ import fs from "node:fs"; import type { GameShop } from "@types"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; -import { SevenZip } from "./7zip"; +import { SevenZip, ExtractionProgress } from "./7zip"; import { WindowManager } from "./window-manager"; import { publishExtractionCompleteNotification } from "./notifications"; import { logger } from "./logger"; +const PROGRESS_THROTTLE_MS = 1000; + export class GameFilesManager { + private lastProgressUpdate = 0; + constructor( private readonly shop: GameShop, private readonly objectId: string ) {} - private async clearExtractionState() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const download = await downloadsSublevel.get(gameKey); + private get gameKey() { + return levelKeys.game(this.shop, this.objectId); + } - await downloadsSublevel.put(gameKey, { - ...download!, + private async updateExtractionProgress(progress: number, force = false) { + const now = Date.now(); + + if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) { + return; + } + + this.lastProgressUpdate = now; + + const download = await downloadsSublevel.get(this.gameKey); + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, + extractionProgress: progress, + }); + + WindowManager.mainWindow?.webContents.send( + "on-extraction-progress", + this.shop, + this.objectId, + progress + ); + } + + private async clearExtractionState() { + const download = await downloadsSublevel.get(this.gameKey); + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -30,6 +64,10 @@ export class GameFilesManager { ); } + private readonly handleProgress = (progress: ExtractionProgress) => { + this.updateExtractionProgress(progress.percent / 100); + }; + async extractFilesInDirectory(directoryPath: string) { if (!fs.existsSync(directoryPath)) return; const files = await fs.promises.readdir(directoryPath); @@ -42,53 +80,66 @@ export class GameFilesManager { (file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file) ); - await Promise.all( - filesToExtract.map((file) => { - return new Promise((resolve, reject) => { - SevenZip.extractFile( - { - filePath: path.join(directoryPath, file), - cwd: directoryPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - () => { - resolve(true); - }, - () => { - reject(new Error(`Failed to extract file: ${file}`)); - this.clearExtractionState(); - } - ); - }); - }) - ); + if (filesToExtract.length === 0) return; - compressedFiles.forEach((file) => { - const extractionPath = path.join(directoryPath, file); + await this.updateExtractionProgress(0, true); - if (fs.existsSync(extractionPath)) { - fs.unlink(extractionPath, (err) => { - if (err) { - logger.error(`Failed to delete file: ${file}`, err); + const totalFiles = filesToExtract.length; + let completedFiles = 0; - this.clearExtractionState(); + for (const file of filesToExtract) { + try { + const result = await SevenZip.extractFile( + { + filePath: path.join(directoryPath, file), + cwd: directoryPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + (progress) => { + const overallProgress = + (completedFiles + progress.percent / 100) / totalFiles; + this.updateExtractionProgress(overallProgress); } - }); + ); + + if (result.success) { + completedFiles++; + await this.updateExtractionProgress( + completedFiles / totalFiles, + true + ); + } + } catch (err) { + logger.error(`Failed to extract file: ${file}`, err); + await this.clearExtractionState(); + return; } - }); + } + + const archivePaths = compressedFiles + .map((file) => path.join(directoryPath, file)) + .filter((archivePath) => fs.existsSync(archivePath)); + + if (archivePaths.length > 0) { + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + archivePaths + ); + } } async setExtractionComplete(publishNotification = true) { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); - await downloadsSublevel.put(gameKey, { - ...download!, + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -97,17 +148,15 @@ export class GameFilesManager { this.objectId ); - if (publishNotification) { - publishExtractionCompleteNotification(game!); + if (publishNotification && game) { + publishExtractionCompleteNotification(game); } } async extractDownloadedFile() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); if (!download || !game) return false; @@ -119,39 +168,39 @@ export class GameFilesManager { path.parse(download.folderName!).name ); - SevenZip.extractFile( - { - filePath, - outputPath: extractionPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - async () => { + await this.updateExtractionProgress(0, true); + + try { + const result = await SevenZip.extractFile( + { + filePath, + outputPath: extractionPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + this.handleProgress + ); + + if (result.success) { await this.extractFilesInDirectory(extractionPath); if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { - fs.unlink(filePath, (err) => { - if (err) { - logger.error( - `Failed to delete file: ${download.folderName}`, - err - ); - - this.clearExtractionState(); - } - }); + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + [filePath] + ); } - await downloadsSublevel.put(gameKey, { - ...download!, + await downloadsSublevel.put(this.gameKey, { + ...download, folderName: path.parse(download.folderName!).name, }); - this.setExtractionComplete(); - }, - () => { - this.clearExtractionState(); + await this.setExtractionComplete(); } - ); + } catch (err) { + logger.error(`Failed to extract downloaded file: ${filePath}`, err); + await this.clearExtractionState(); + } return true; } diff --git a/src/main/services/hosters/gofile.ts b/src/main/services/hosters/gofile.ts index 5560ad31..fb9b97e3 100644 --- a/src/main/services/hosters/gofile.ts +++ b/src/main/services/hosters/gofile.ts @@ -36,16 +36,13 @@ export class GofileApi { } public static async getDownloadLink(id: string) { - const searchParams = new URLSearchParams({ - wt: WT, - }); - const response = await axios.get<{ status: string; data: GofileContentsResponse; - }>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, { + }>(`https://api.gofile.io/contents/${id}`, { headers: { Authorization: `Bearer ${this.token}`, + "X-Website-Token": WT, }, }); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index a5a78e4a..fa712105 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -30,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = true; + private static readonly ADD_LOG_INTERCEPTOR = false; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; @@ -58,7 +58,13 @@ export class HydraApi { const decodedBase64 = atob(payload as string); const jsonData = JSON.parse(decodedBase64); - const { accessToken, expiresIn, refreshToken } = jsonData; + const { + accessToken, + expiresIn, + refreshToken, + featurebaseJwt, + workwondersJwt, + } = jsonData; const now = new Date(); @@ -85,6 +91,8 @@ export class HydraApi { accessToken, refreshToken, tokenExpirationTimestamp, + featurebaseJwt, + workwondersJwt, }, { valueEncoding: "json" } ); diff --git a/src/main/services/node-7z.d.ts b/src/main/services/node-7z.d.ts new file mode 100644 index 00000000..3877346a --- /dev/null +++ b/src/main/services/node-7z.d.ts @@ -0,0 +1,87 @@ +declare module "node-7z" { + import { ChildProcess } from "node:child_process"; + import { EventEmitter } from "node:events"; + + export interface CommandLineSwitches { + $bin?: string; + $progress?: boolean; + $spawnOptions?: { + cwd?: string; + }; + outputDir?: string; + yes?: boolean; + password?: string; + [key: string]: unknown; + } + + export interface ProgressInfo { + percent: number; + fileCount?: number; + } + + export interface FileInfo { + file?: string; + [key: string]: unknown; + } + + export interface ZipStream extends EventEmitter { + on(event: "progress", listener: (progress: ProgressInfo) => void): this; + on(event: "data", listener: (data: FileInfo) => void): this; + on(event: "end", listener: () => void): this; + on(event: "error", listener: (err: Error) => void): this; + info: Map; + _childProcess?: ChildProcess; + } + + export function extractFull( + archive: string, + output: string, + options?: CommandLineSwitches + ): ZipStream; + + export function extract( + archive: string, + output: string, + options?: CommandLineSwitches + ): ZipStream; + + export function list( + archive: string, + options?: CommandLineSwitches + ): ZipStream; + + export function add( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function update( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function deleteFiles( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function test( + archive: string, + options?: CommandLineSwitches + ): ZipStream; + + const Seven: { + extractFull: typeof extractFull; + extract: typeof extract; + list: typeof list; + add: typeof add; + update: typeof update; + delete: typeof deleteFiles; + test: typeof test; + }; + + export default Seven; +} diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index b8ff480c..a925e7c7 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -15,6 +15,13 @@ import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; import { getThemeSoundPath } from "@main/helpers"; +import { processProfileImage } from "@main/events/profile/process-profile-image"; + +const getStaticImage = async (path: string) => { + return processProfileImage(path, "jpg") + .then((response) => response.imagePath) + .catch(() => path); +}; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -31,8 +38,9 @@ async function downloadImage(url: string | null) { response.data.pipe(writer); return new Promise((resolve) => { - writer.on("finish", () => { - resolve(outputPath); + writer.on("finish", async () => { + const staticImagePath = await getStaticImage(outputPath); + resolve(staticImagePath); }); writer.on("error", () => { logger.error("Failed to download image", { url }); diff --git a/src/main/services/system-path.ts b/src/main/services/system-path.ts index 32b34e11..0b42b0aa 100644 --- a/src/main/services/system-path.ts +++ b/src/main/services/system-path.ts @@ -13,9 +13,9 @@ export class SystemPath { }; static checkIfPathsAreAvailable() { - const paths = Object.keys(SystemPath.paths) as Array< - keyof typeof SystemPath.paths - >; + const paths = Object.keys( + SystemPath.paths + ) as (keyof typeof SystemPath.paths)[]; paths.forEach((pathName) => { try { diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index b11b4a9b..04c77619 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -138,7 +138,8 @@ export class WindowManager { (details, callback) => { if ( details.webContentsId !== this.mainWindow?.webContents.id || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } @@ -159,7 +160,8 @@ export class WindowManager { if ( details.webContentsId !== this.mainWindow?.webContents.id || details.url.includes("featurebase") || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } diff --git a/src/main/services/ws/events/friend-request.ts b/src/main/services/ws/events/friend-request.ts index 8faa38a5..efee370d 100644 --- a/src/main/services/ws/events/friend-request.ts +++ b/src/main/services/ws/events/friend-request.ts @@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => { friendRequestCount: payload.friendRequestCount, }); - const user = await HydraApi.get(`/users/${payload.senderId}`); + if (payload.senderId) { + const user = await HydraApi.get(`/users/${payload.senderId}`); - if (user) { - publishNewFriendRequestNotification(user); + if (user) { + publishNewFriendRequestNotification(user); + } } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index a2965532..5579b6fb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -267,6 +267,29 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-extraction-complete", listener); return () => ipcRenderer.removeListener("on-extraction-complete", listener); }, + onExtractionProgress: ( + cb: (shop: GameShop, objectId: string, progress: number) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + shop: GameShop, + objectId: string, + progress: number + ) => cb(shop, objectId, progress); + ipcRenderer.on("on-extraction-progress", listener); + return () => ipcRenderer.removeListener("on-extraction-progress", listener); + }, + onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + archivePaths: string[] + ) => cb(archivePaths); + ipcRenderer.on("on-archive-deletion-prompt", listener); + return () => + ipcRenderer.removeListener("on-archive-deletion-prompt", listener); + }, + deleteArchive: (filePath: string) => + ipcRenderer.invoke("deleteArchive", filePath), /* Hardware */ getDiskFreeSpace: (path: string) => @@ -619,4 +642,28 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), + + /* LevelDB Generic CRUD */ + leveldb: { + get: ( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding), + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => + ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding), + del: (key: string, sublevelName?: string | null) => + ipcRenderer.invoke("leveldbDel", key, sublevelName), + clear: (sublevelName: string) => + ipcRenderer.invoke("leveldbClear", sublevelName), + values: (sublevelName: string) => + ipcRenderer.invoke("leveldbValues", sublevelName), + iterator: (sublevelName: string) => + ipcRenderer.invoke("leveldbIterator", sublevelName), + }, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 42166e56..6284effc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra Launcher diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 391e9c03..6619c890 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -19,11 +19,14 @@ import { setUserDetails, setProfileBackground, setGameRunning, + setExtractionProgress, + clearExtraction, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; +import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal"; import { injectCustomCss, @@ -31,6 +34,8 @@ import { getAchievementSoundUrl, getAchievementSoundVolume, } from "./helpers"; +import { levelDBService } from "./services/leveldb.service"; +import type { UserPreferences } from "@types"; import "./app.scss"; export interface AppProps { @@ -76,12 +81,17 @@ export function App() { const { showSuccessToast } = useToast(); + const [showArchiveDeletionModal, setShowArchiveDeletionModal] = + useState(false); + const [archivePaths, setArchivePaths] = useState([]); + useEffect(() => { - Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then( - ([preferences]) => { - dispatch(setUserPreferences(preferences)); - } - ); + Promise.all([ + levelDBService.get("userPreferences", null, "json"), + updateLibrary(), + ]).then(([preferences]) => { + dispatch(setUserPreferences(preferences as UserPreferences | null)); + }); }, [navigate, location.pathname, dispatch, updateLibrary]); useEffect(() => { @@ -181,12 +191,23 @@ export function App() { updateLibrary(); }), window.electron.onSignOut(() => clearUserDetails()), + window.electron.onExtractionProgress((shop, objectId, progress) => { + dispatch(setExtractionProgress({ shop, objectId, progress })); + }), + window.electron.onExtractionComplete(() => { + dispatch(clearExtraction()); + updateLibrary(); + }), + window.electron.onArchiveDeletionPrompt((paths) => { + setArchivePaths(paths); + setShowArchiveDeletionModal(true); + }), ]; return () => { listeners.forEach((unsubscribe) => unsubscribe()); }; - }, [onSignIn, updateLibrary, clearUserDetails]); + }, [onSignIn, updateLibrary, clearUserDetails, dispatch]); useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; @@ -204,7 +225,11 @@ export function App() { }, [dispatch, draggingDisabled]); const loadAndApplyTheme = useCallback(async () => { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + code?: string; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.code) { injectCustomCss(activeTheme.code); } else { @@ -274,6 +299,12 @@ export function App() { feature={hydraCloudFeature} /> + setShowArchiveDeletionModal(false)} + /> + {userDetails && ( state.download.extraction); + const [version, setVersion] = useState(""); const [sessionHash, setSessionHash] = useState(""); const [commonRedistStatus, setCommonRedistStatus] = useState( @@ -68,6 +71,20 @@ export function BottomPanel() { return t("installing_common_redist", { log: commonRedistStatus }); } + if (extraction) { + const extractingGame = library.find( + (game) => game.id === extraction.visibleId + ); + + if (extractingGame) { + const extractionPercentage = Math.round(extraction.progress * 100); + return t("extracting", { + title: extractingGame.title, + percentage: `${extractionPercentage}%`, + }); + } + } + const game = lastPacket ? library.find((game) => game.id === lastPacket?.gameId) : undefined; @@ -109,6 +126,7 @@ export function BottomPanel() { eta, downloadSpeed, commonRedistStatus, + extraction, ]); return ( @@ -122,10 +140,10 @@ export function BottomPanel() { +
+
    + {historyItems.map((item, index) => ( +
  • + + +
  • + ))} +
+ + )} + + {hasSuggestions && ( +
+
+ + {t("suggestions")} + +
+
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {isLoadingSuggestions && !hasSuggestions && !hasHistory && ( +
{t("loading")}
+ )} + + {!isLoadingSuggestions && + !hasHistory && + !hasSuggestions && + totalItems === 0 && ( +
{t("no_results")}
+ )} + + ); + + return createPortal(dropdownContent, document.body); +} diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index bc1a6351..29feabf5 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,6 +1,8 @@ import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { setHeaderTitle } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import { getSteamLanguage } from "@renderer/helpers"; import { useAppDispatch, @@ -10,6 +12,7 @@ import { } from "@renderer/hooks"; import type { + DownloadSource, GameRepack, GameShop, GameStats, @@ -297,7 +300,10 @@ export function GameDetailsContextProvider({ const fetchDownloadSources = async () => { try { - const sources = await window.electron.getDownloadSources(); + const sourcesRaw = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sources = orderBy(sourcesRaw, "createdAt", "desc"); const params = { take: 100, diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 1160ca3e..338c4e45 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useEffect, useState } from "react"; import { setUserPreferences } from "@renderer/features"; import { useAppDispatch } from "@renderer/hooks"; +import { levelDBService } from "@renderer/services/leveldb.service"; import type { UserBlocks, UserPreferences } from "@types"; import { useSearchParams } from "react-router-dom"; @@ -134,9 +135,11 @@ export function SettingsContextProvider({ const updateUserPreferences = async (values: Partial) => { await window.electron.updateUserPreferences(values); - window.electron.getUserPreferences().then((userPreferences) => { - dispatch(setUserPreferences(userPreferences)); - }); + levelDBService + .get("userPreferences", null, "json") + .then((userPreferences) => { + dispatch(setUserPreferences(userPreferences as UserPreferences | null)); + }); }; return ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e35ed57b..6975967e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -208,6 +208,13 @@ declare global { onExtractionComplete: ( cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; + onExtractionProgress: ( + cb: (shop: GameShop, objectId: string, progress: number) => void + ) => () => Electron.IpcRenderer; + onArchiveDeletionPrompt: ( + cb: (archivePaths: string[]) => void + ) => () => Electron.IpcRenderer; + deleteArchive: (filePath: string) => Promise; getDefaultWinePrefixSelectionPath: () => Promise; createSteamShortcut: (shop: GameShop, objectId: string) => Promise; @@ -438,6 +445,25 @@ declare global { onNewDownloadOptions: ( cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void ) => () => Electron.IpcRenderer; + + /* LevelDB Generic CRUD */ + leveldb: { + get: ( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + del: (key: string, sublevelName?: string | null) => Promise; + clear: (sublevelName: string) => Promise; + values: (sublevelName: string) => Promise; + iterator: (sublevelName: string) => Promise<[string, unknown][]>; + }; } interface Window { diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index cb638cda..0330cca3 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -1,17 +1,24 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import type { DownloadProgress } from "@types"; +import type { DownloadProgress, GameShop } from "@types"; + +export interface ExtractionInfo { + visibleId: string; + progress: number; +} export interface DownloadState { lastPacket: DownloadProgress | null; gameId: string | null; gamesWithDeletionInProgress: string[]; + extraction: ExtractionInfo | null; } const initialState: DownloadState = { lastPacket: null, gameId: null, gamesWithDeletionInProgress: [], + extraction: null, }; export const downloadSlice = createSlice({ @@ -38,6 +45,23 @@ export const downloadSlice = createSlice({ const index = state.gamesWithDeletionInProgress.indexOf(action.payload); if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1); }, + setExtractionProgress: ( + state, + action: PayloadAction<{ + shop: GameShop; + objectId: string; + progress: number; + }> + ) => { + const { shop, objectId, progress } = action.payload; + state.extraction = { + visibleId: `${shop}:${objectId}`, + progress, + }; + }, + clearExtraction: (state) => { + state.extraction = null; + }, }, }); @@ -46,4 +70,6 @@ export const { clearDownload, setGameDeleting, removeGameFromDeleting, + setExtractionProgress, + clearExtraction, } = downloadSlice.actions; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index e16aa7a4..0b057754 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -3,6 +3,7 @@ import type { GameShop } from "@types"; import Color from "color"; import { v4 as uuidv4 } from "uuid"; import { THEME_WEB_STORE_URL } from "./constants"; +import { levelDBService } from "./services/leveldb.service"; export const formatDownloadProgress = ( progress?: number, @@ -127,7 +128,12 @@ export const getAchievementSoundUrl = async (): Promise => { .default; try { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + hasCustomSound?: boolean; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.hasCustomSound) { const soundDataUrl = await window.electron.getThemeSoundDataUrl( @@ -146,10 +152,18 @@ export const getAchievementSoundUrl = async (): Promise => { export const getAchievementSoundVolume = async (): Promise => { try { - const prefs = await window.electron.getUserPreferences(); + const prefs = (await levelDBService.get( + "userPreferences", + null, + "json" + )) as { achievementSoundVolume?: number } | null; return prefs?.achievementSoundVolume ?? 0.15; } catch (error) { console.error("Failed to get sound volume", error); return 0.15; } }; + +export const getGameKey = (shop: GameShop, objectId: string): string => { + return `${shop}:${objectId}`; +}; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4d34f219..a1666ede 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,3 +8,6 @@ export * from "./use-format"; export * from "./use-feature"; export * from "./use-download-options-listener"; export * from "./use-game-card"; +export * from "./use-search-history"; +export * from "./use-search-suggestions"; +export * from "./use-hls-video"; diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts index 675f5013..ca2aaa4a 100644 --- a/src/renderer/src/hooks/use-catalogue.ts +++ b/src/renderer/src/hooks/use-catalogue.ts @@ -1,8 +1,9 @@ import axios from "axios"; import { useCallback, useEffect, useState } from "react"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import type { DownloadSource } from "@types"; import { useAppDispatch } from "./redux"; import { setGenres, setTags } from "@renderer/features"; -import type { DownloadSource } from "@types"; export const externalResourcesInstance = axios.create({ baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, @@ -40,8 +41,9 @@ export function useCatalogue() { }, []); const getDownloadSources = useCallback(() => { - window.electron.getDownloadSources().then((results) => { - setDownloadSources(results.filter((source) => !!source.fingerprint)); + levelDBService.values("downloadSources").then((results) => { + const sources = results as DownloadSource[]; + setDownloadSources(sources.filter((source) => !!source.fingerprint)); }); }, []); diff --git a/src/renderer/src/hooks/use-hls-video.ts b/src/renderer/src/hooks/use-hls-video.ts new file mode 100644 index 00000000..eea4065d --- /dev/null +++ b/src/renderer/src/hooks/use-hls-video.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef } from "react"; +import Hls from "hls.js"; +import { logger } from "@renderer/logger"; + +interface UseHlsVideoOptions { + videoSrc: string | undefined; + videoType: string | undefined; + autoplay?: boolean; + muted?: boolean; + loop?: boolean; +} + +export function useHlsVideo( + videoRef: React.RefObject, + { videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions +) { + const hlsRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video || !videoSrc) return; + + const isHls = videoType === "application/x-mpegURL"; + + if (!isHls) { + return undefined; + } + + if (Hls.isSupported()) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + }); + + hlsRef.current = hls; + + hls.loadSource(videoSrc); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (autoplay) { + video.play().catch((err) => { + logger.warn("Failed to autoplay HLS video:", err); + }); + } + }); + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + logger.error("HLS network error, trying to recover"); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + logger.error("HLS media error, trying to recover"); + hls.recoverMediaError(); + break; + default: + logger.error("HLS fatal error, destroying instance"); + hls.destroy(); + break; + } + } + }); + + return () => { + hls.destroy(); + hlsRef.current = null; + }; + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = videoSrc; + video.load(); + if (autoplay) { + video.play().catch((err) => { + logger.warn("Failed to autoplay HLS video:", err); + }); + } + + return () => { + video.src = ""; + }; + } else { + logger.warn("HLS playback is not supported in this browser"); + return undefined; + } + }, [videoRef, videoSrc, videoType, autoplay, muted, loop]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (muted !== undefined) { + video.muted = muted; + } + if (loop !== undefined) { + video.loop = loop; + } + }, [videoRef, muted, loop]); + + return hlsRef.current; +} diff --git a/src/renderer/src/hooks/use-search-history.ts b/src/renderer/src/hooks/use-search-history.ts new file mode 100644 index 00000000..e5ce3efa --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -0,0 +1,89 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { levelDBService } from "@renderer/services/leveldb.service"; + +export interface SearchHistoryEntry { + query: string; + timestamp: number; + context: "library" | "catalogue"; +} + +const LEVELDB_KEY = "searchHistory"; +const MAX_HISTORY_ENTRIES = 15; + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + const isInitialized = useRef(false); + + useEffect(() => { + const loadHistory = async () => { + if (isInitialized.current) return; + isInitialized.current = true; + + try { + const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as + | SearchHistoryEntry[] + | null; + + if (data) { + setHistory(data); + } + } catch { + setHistory([]); + } + }; + + loadHistory(); + }, []); + + const addToHistory = useCallback( + (query: string, context: "library" | "catalogue") => { + if (!query.trim()) return; + + const newEntry: SearchHistoryEntry = { + query: query.trim(), + timestamp: Date.now(), + context, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() + ); + const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); + levelDBService.put(LEVELDB_KEY, updated, null, "json"); + return updated; + }); + }, + [] + ); + + const removeFromHistory = useCallback((query: string) => { + setHistory((prev) => { + const updated = prev.filter((entry) => entry.query !== query); + levelDBService.put(LEVELDB_KEY, updated, null, "json"); + return updated; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + levelDBService.del(LEVELDB_KEY, null); + }, []); + + const getRecentHistory = useCallback( + (context: "library" | "catalogue", limit: number = 3) => { + return history + .filter((entry) => entry.context === context) + .slice(0, limit); + }, + [history] + ); + + return { + history, + addToHistory, + removeFromHistory, + clearHistory, + getRecentHistory, + }; +} diff --git a/src/renderer/src/hooks/use-search-suggestions.ts b/src/renderer/src/hooks/use-search-suggestions.ts new file mode 100644 index 00000000..b8986775 --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,163 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; +import { logger } from "@renderer/logger"; +import type { GameShop } from "@types"; + +export interface SearchSuggestion { + title: string; + objectId: string; + shop: GameShop; + iconUrl: string | null; + source: "library" | "catalogue"; +} + +export function useSearchSuggestions( + query: string, + isOnLibraryPage: boolean, + enabled: boolean = true +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const library = useAppSelector((state) => state.library.value); + const abortControllerRef = useRef(null); + const cacheRef = useRef>(new Map()); + + const getLibrarySuggestions = useCallback( + (searchQuery: string, limit: number = 3): SearchSuggestion[] => { + if (!searchQuery.trim()) return []; + + const queryLower = searchQuery.toLowerCase(); + const matches: SearchSuggestion[] = []; + + for (const game of library) { + if (matches.length >= limit) break; + + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + if (queryIndex === queryLower.length) { + matches.push({ + title: game.title, + objectId: game.objectId, + shop: game.shop, + iconUrl: game.iconUrl, + source: "library", + }); + } + } + + return matches; + }, + [library] + ); + + const fetchCatalogueSuggestions = useCallback( + async (searchQuery: string, limit: number = 3) => { + if (!searchQuery.trim() || searchQuery.length < 2) { + setSuggestions([]); + setIsLoading(false); + return; + } + + const cacheKey = `${searchQuery.toLowerCase()}_${limit}`; + const cachedResults = cacheRef.current.get(cacheKey); + + if (cachedResults) { + setSuggestions(cachedResults); + setIsLoading(false); + return; + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + try { + const response = await window.electron.hydraApi.get< + { + title: string; + objectId: string; + shop: GameShop; + iconUrl: string | null; + }[] + >("/catalogue/search/suggestions", { + params: { + query: searchQuery, + limit, + }, + needsAuth: false, + }); + + if (abortController.signal.aborted) return; + + const catalogueSuggestions: SearchSuggestion[] = response.map( + (item) => ({ + ...item, + source: "catalogue" as const, + }) + ); + + cacheRef.current.set(cacheKey, catalogueSuggestions); + setSuggestions(catalogueSuggestions); + } catch (error) { + if (!abortController.signal.aborted) { + setSuggestions([]); + logger.error("Failed to fetch catalogue suggestions", error); + } + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, + [] + ); + + const debouncedFetchCatalogue = useRef( + debounce(fetchCatalogueSuggestions, 300) + ).current; + + useEffect(() => { + if (!enabled || !query || query.length < 2) { + setSuggestions([]); + setIsLoading(false); + abortControllerRef.current?.abort(); + debouncedFetchCatalogue.cancel(); + return; + } + + if (isOnLibraryPage) { + const librarySuggestions = getLibrarySuggestions(query, 3); + setSuggestions(librarySuggestions); + setIsLoading(false); + } else { + debouncedFetchCatalogue(query, 3); + } + + return () => { + debouncedFetchCatalogue.cancel(); + abortControllerRef.current?.abort(); + }; + }, [ + query, + isOnLibraryPage, + enabled, + getLibrarySuggestions, + debouncedFetchCatalogue, + ]); + + return { suggestions, isLoading }; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 84c7f815..92220a6e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -21,6 +21,7 @@ import resources from "@locales"; import { logger } from "./logger"; import { addCookieInterceptor } from "./cookies"; +import { levelDBService } from "./services/leveldb.service"; import Catalogue from "./pages/catalogue/catalogue"; import Home from "./pages/home/home"; import Downloads from "./pages/downloads/downloads"; @@ -48,7 +49,11 @@ i18n }, }) .then(async () => { - const userPreferences = await window.electron.getUserPreferences(); + const userPreferences = (await levelDBService.get( + "userPreferences", + null, + "json" + )) as { language?: string } | null; if (userPreferences?.language) { i18n.changeLanguage(userPreferences.language); diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index 38b2443b..a362c545 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -11,6 +11,7 @@ import { getAchievementSoundVolume, } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; +import { levelDBService } from "@renderer/services/leveldb.service"; import app from "../../../app.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; import root from "react-shadow"; @@ -144,7 +145,11 @@ export function AchievementNotification() { const loadAndApplyTheme = useCallback(async () => { if (!shadowRootRef) return; - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + code?: string; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.code) { injectCustomCss(activeTheme.code, shadowRootRef); } else { diff --git a/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx new file mode 100644 index 00000000..ff931a61 --- /dev/null +++ b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { ConfirmationModal } from "@renderer/components"; + +interface ArchiveDeletionModalProps { + visible: boolean; + archivePaths: string[]; + onClose: () => void; +} + +export function ArchiveDeletionModal({ + visible, + archivePaths, + onClose, +}: Readonly) { + const { t } = useTranslation("downloads"); + + const fullFileName = + archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : ""; + + const maxLength = 40; + const fileName = + fullFileName.length > maxLength + ? `${fullFileName.slice(0, maxLength)}…` + : fullFileName; + + const handleConfirm = async () => { + for (const archivePath of archivePaths) { + await window.electron.deleteArchive(archivePath); + } + onClose(); + }; + + return ( + + ); +} diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b..bfd8fbda 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -4,158 +4,541 @@ display: flex; flex-direction: column; gap: calc(globals.$spacing-unit * 2); + margin-inline: calc(globals.$spacing-unit * 3); + padding-block: calc(globals.$spacing-unit * 3); - &__details-with-article { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit / 2); - align-self: flex-start; - cursor: pointer; + &--queued { + padding-bottom: 0; + } + + &--completed { + padding-top: calc(globals.$spacing-unit * 3); } &__header { display: flex; align-items: center; - justify-content: space-between; - gap: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit); - &-divider { + &-title-group { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); flex: 1; - background-color: globals.$border-color; - height: 1px; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + } } &-count { - font-weight: 400; + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + flex-shrink: 0; } } - - &__title-wrapper { - display: flex; - align-items: center; - margin-bottom: globals.$spacing-unit; - gap: globals.$spacing-unit; - } - - &__title { - font-weight: bold; - cursor: pointer; - color: globals.$body-color; - text-align: left; - font-size: 16px; - display: block; - - &:hover { - text-decoration: underline; - } - } - - &__downloads { + &--hero { width: 100%; - gap: calc(globals.$spacing-unit * 2); - display: flex; - flex-direction: column; + position: relative; + overflow: hidden; margin: 0; padding: 0; - margin-top: globals.$spacing-unit; + padding-bottom: globals.$spacing-unit; } - &__item { + &__hero-background { + position: absolute; + top: 0; + left: 0; width: 100%; - background-color: globals.$background-color; - display: flex; - border-radius: 8px; - border: solid 1px globals.$border-color; - overflow: hidden; - box-shadow: 0px 0px 5px 0px #000000; - transition: all ease 0.2s; - height: 140px; - min-height: 140px; - max-height: 140px; - position: relative; + height: 120%; + z-index: 0; - &--hydra { - box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 20%; } } - &__cover { - width: 280px; - min-width: 280px; - height: auto; - border-right: solid 1px globals.$border-color; + // PLEASE FIX THE COLORS + &__hero-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.3) 0%, + rgb(5, 5, 5) 70%, + rgb(26, 26, 26) 100% + ); + } + + &__hero-content { position: relative; z-index: 1; - - &-content { - width: 100%; - height: 100%; - padding: globals.$spacing-unit; - display: flex; - align-items: flex-end; - justify-content: flex-end; - } - - &-backdrop { - width: 100%; - height: 100%; - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.8) 5%, - transparent 100% - ); - display: flex; - overflow: hidden; - z-index: 1; - } - - &-image { - width: 100%; - height: 100%; - position: absolute; - z-index: -1; - } - } - - &__right-content { - display: flex; - padding: calc(globals.$spacing-unit * 2); - flex: 1; - gap: globals.$spacing-unit; - background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%); - } - - &__details { + padding: calc(globals.$spacing-unit * 4); + padding-bottom: 0; display: flex; flex-direction: column; - flex: 1; - justify-content: center; - gap: calc(globals.$spacing-unit / 2); - font-size: 14px; + gap: calc(globals.$spacing-unit * 2); } - &__actions { + &__hero-logo { + flex: 1; + min-width: 0; display: flex; align-items: center; - gap: globals.$spacing-unit; + + &-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + transition: scale 0.2s ease; + outline: none; + + &:hover { + scale: 1.05; + } + } + + img { + max-width: 180px; + max-height: 60px; + object-fit: contain; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + &:focus { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 4px; + border-radius: 4px; + } + + @container #{globals.$app-container} (min-width: 700px) { + max-width: 220px; + max-height: 75px; + } + + @container #{globals.$app-container} (min-width: 900px) { + max-width: 280px; + max-height: 95px; + } + + @container #{globals.$app-container} (min-width: 1200px) { + max-width: 340px; + max-height: 115px; + } + + @container #{globals.$app-container} (min-width: 1500px) { + max-width: 400px; + max-height: 130px; + } + } + + h1 { + font-size: 20px; + font-weight: 700; + color: #ffffff; + text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9); + margin: 0; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + &:focus { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 4px; + border-radius: 4px; + } + + @container #{globals.$app-container} (min-width: 700px) { + font-size: 26px; + } + + @container #{globals.$app-container} (min-width: 900px) { + font-size: 32px; + } + + @container #{globals.$app-container} (min-width: 1200px) { + font-size: 38px; + } + + @container #{globals.$app-container} (min-width: 1500px) { + font-size: 44px; + } + } } - &__menu-button { - position: absolute; - top: 12px; - right: 12px; - border-radius: 50%; + &__hero-action-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: calc(globals.$spacing-unit * 3); + margin-top: calc(globals.$spacing-unit * 4); + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__hero-buttons { + display: flex; + gap: calc(globals.$spacing-unit); + align-items: center; + flex-shrink: 0; + } + + &__glass-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -4px rgba(0, 0, 0, 0.1); + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + } + + &__hero-progress { + display: flex; + flex-direction: column; + } + + &__progress-info-row { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + } + + &__progress-row { + display: flex; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 2); + + &--bar { + margin-top: calc(globals.$spacing-unit); + } + } + + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__progress-percentage { + font-size: 14px; + font-weight: 700; + color: #ffffff; + align-self: flex-end; + display: inline-block; + overflow: hidden; + line-height: 1.2; + + span { + display: inline-block; + } + } + + &__progress-size { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + } + + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + } + + &__progress-time { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 13px; + color: globals.$muted-color; + } + + &__hero-stats { + display: flex; + gap: calc(globals.$spacing-unit * 4); + padding: calc(globals.$spacing-unit * 2); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(26, 26, 26, 0.1); + backdrop-filter: blur(8px); + margin-top: calc(globals.$spacing-unit * 2); + } + + &__stats-column { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + min-width: 200px; + padding-right: calc(globals.$spacing-unit * 2); + border-right: 1px solid rgba(255, 255, 255, 0.1); + align-self: flex-start; + } + + &__speed-chart { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + &__speed-chart-canvas { + width: 100%; + height: 80px; + image-rendering: crisp-edges; + } + + &__stat-item { + display: flex; + align-items: flex-end; + gap: calc(globals.$spacing-unit); + + svg { + opacity: 0.8; + flex-shrink: 0; + } + } + + &__stat-content { + display: flex; + justify-content: space-between; + gap: calc(globals.$spacing-unit / 2); + width: 100%; + } + + &__stat-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + } + + &__stat-value { + color: #ffffff; + font-weight: 700; + font-size: 11px; + line-height: 1.2; + } + + &__simple-list { + width: 100%; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + margin: 0; + padding: 0; + list-style: none; + } + + &__simple-card { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + border-radius: 8px; + } + + &__simple-thumbnail { + width: 200px; + height: 100px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + background-color: rgba(0, 0, 0, 0.3); + border: 1px solid globals.$border-color; + padding: 0; + cursor: pointer; + transition: + opacity 0.2s ease, + transform 0.2s ease; + + &:hover { + opacity: 0.9; + } + + &:focus, + &:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 2px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__simple-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 1); + } + + &__simple-title-button { + background: none; border: none; - padding: 8px; + padding: 0; + cursor: pointer; + text-align: left; + width: 100%; + transition: opacity 0.2s ease; + + &:focus, + &:focus-visible { + outline: none; + } + } + + &__simple-title { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__simple-meta { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + } + + &__simple-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + font-size: 13px; + color: globals.$muted-color; + } + + &__simple-size { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + font-weight: 500; + } + + &__simple-extracting { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + font-weight: 500; + color: globals.$muted-color; + } + + &__simple-seeding { + color: #4ade80; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__simple-progress { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + width: 200px; + flex-shrink: 0; + } + + &__simple-progress-text { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + text-align: right; + } + + &__simple-actions { + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__simple-menu-btn { + padding: calc(globals.$spacing-unit); min-height: unset; } - &__hydra-gradient { - background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); - box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); + &__progress-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + } + + &__progress-bar { width: 100%; - position: absolute; - bottom: 0; - height: 2px; - z-index: 1; + height: 8px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + margin-top: calc(globals.$spacing-unit / 2); + + &--small { + height: 6px; + } + } + + &__progress-fill { + height: 100%; + background-color: #fff; + transition: width 0.3s ease; + border-radius: 4px; + + &--extraction { + background-color: #fff; + } } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..52fbcdfd 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,37 +1,438 @@ -import { useNavigate } from "react-router-dom"; -import cn from "classnames"; - import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; import { - buildGameDetailsPath, formatDownloadProgress, + buildGameDetailsPath, } from "@renderer/helpers"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, formatBytesToMbps } from "@shared"; +import { addMilliseconds } from "date-fns"; import { DOWNLOADER_NAME } from "@renderer/constants"; -import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; +import { + useAppSelector, + useDownload, + useLibrary, + useDate, +} from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; import { DropdownMenu, DropdownMenuItem, } from "@renderer/components/dropdown-menu/dropdown-menu"; import { + ClockIcon, ColumnsIcon, DownloadIcon, FileDirectoryIcon, LinkIcon, PlayIcon, - QuestionIcon, ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, + GraphIcon, } from "@primer/octicons-react"; +import { average } from "color.js"; + +interface AnimatedPercentageProps { + value: number; +} + +function AnimatedPercentage({ value }: Readonly) { + const percentageText = formatDownloadProgress(value); + const prevTextRef = useRef(percentageText); + const chars = percentageText.split(""); + const prevChars = prevTextRef.current.split(""); + + useEffect(() => { + prevTextRef.current = percentageText; + }, [percentageText]); + + return ( + <> + {chars.map((char, index) => { + const prevChar = prevChars[index]; + const charChanged = prevChar !== char; + + return ( + + + {char} + + + ); + })} + + ); +} + +interface SpeedChartProps { + speeds: number[]; + peakSpeed: number; + color?: string; +} + +function SpeedChart({ + speeds, + peakSpeed, + color = "rgba(255, 255, 255, 1)", +}: Readonly) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationFrameId: number; + let resizeObserver: ResizeObserver | null = null; + + const draw = () => { + const clientWidth = canvas.clientWidth; + const dpr = window.devicePixelRatio || 1; + + canvas.width = clientWidth * dpr; + canvas.height = 100 * dpr; + ctx.scale(dpr, dpr); + + const width = clientWidth; + const height = 100; + const barWidth = 4; + const barGap = 10; + const barSpacing = barWidth + barGap; + + // Calculate how many bars can fit in the available width + const totalBars = Math.max(1, Math.floor((width + barGap) / barSpacing)); + const maxHeight = peakSpeed || Math.max(...speeds, 1); + + ctx.clearRect(0, 0, width, height); + + let r = 255, + g = 255, + b = 255; + if (color.startsWith("#")) { + let hex = color.replace("#", ""); + // Handle shorthand hex colors (e.g., "#fff" -> "#ffffff") + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + r = Number.parseInt(hex.substring(0, 2), 16) || 255; + g = Number.parseInt(hex.substring(2, 4), 16) || 255; + b = Number.parseInt(hex.substring(4, 6), 16) || 255; + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = Number.parseInt(matches[0]) || 255; + g = Number.parseInt(matches[1]) || 255; + b = Number.parseInt(matches[2]) || 255; + } + } + const displaySpeeds = speeds.slice(-totalBars); + + for (let i = 0; i < totalBars; i++) { + const x = i * barSpacing; + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(x, 0, barWidth, height, 3); + ctx.fill(); + + if (i < displaySpeeds.length) { + const speed = displaySpeeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); + ctx.fill(); + } + } + } + animationFrameId = requestAnimationFrame(draw); + }; + + animationFrameId = requestAnimationFrame(draw); + + // Handle resize - trigger redraw when canvas size changes + resizeObserver = new ResizeObserver(() => { + // Cancel any pending animation frame to force immediate redraw + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + // Trigger a redraw that will recalculate bars based on new width + draw(); + }); + resizeObserver.observe(canvas); + + return () => { + cancelAnimationFrame(animationFrameId); + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, [speeds, peakSpeed, color]); + + return ( + + ); +} + +interface HeroDownloadViewProps { + game: LibraryGame; + isGameDownloading: boolean; + isGameExtracting?: boolean; + downloadSpeed: number; + finalDownloadSize: string; + peakSpeed: number; + currentProgress: number; + dominantColor: string; + lastPacket: ReturnType["lastPacket"]; + speedHistory: number[]; + formatSpeed: (speed: number) => string; + calculateETA: () => string; + pauseDownload: (shop: GameShop, objectId: string) => void; + resumeDownload: (shop: GameShop, objectId: string) => void; + cancelDownload: (shop: GameShop, objectId: string) => void; + t: (key: string) => string; +} + +function HeroDownloadView({ + game, + isGameDownloading, + isGameExtracting = false, + downloadSpeed, + finalDownloadSize, + peakSpeed, + currentProgress, + dominantColor, + lastPacket, + speedHistory, + formatSpeed, + calculateETA, + pauseDownload, + resumeDownload, + cancelDownload, + t, +}: Readonly) { + const navigate = useNavigate(); + + const handleLogoClick = useCallback(() => { + navigate(buildGameDetailsPath(game)); + }, [navigate, game]); + + return ( +
+
+ {game.title} +
+
+ +
+
+
+ {game.logoImageUrl ? ( + + ) : ( + + )} +
+
+ +
+
+
+
+ {isGameExtracting && ( + + {t("extracting")} + + )} + {!isGameExtracting && lastPacket?.isCheckingFiles && ( + + {t("checking_files")} + + )} + {!isGameExtracting && !lastPacket?.isCheckingFiles && ( + + + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + )} + +
+
+ {!lastPacket?.isCheckingFiles && !isGameExtracting && ( + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 && ( + <> + + {calculateETA()} + + )} + + )} + + + +
+
+
+
+
+ {!isGameExtracting && ( +
+ {isGameDownloading ? ( + + ) : ( + + )} + +
+ )} +
+
+ +
+
+
+ + + +
+ + {t("network")}: + + + {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} + +
+
+ +
+ + + +
+ {t("peak")}: + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
+
+ + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
+
+ + Seeds:{" "} + + {lastPacket.numSeeds} + + , Peers:{" "} + + {lastPacket.numPeers} + + +
+
+ )} + + {game.download?.downloader && ( +
+
+ {DOWNLOADER_NAME[game.download.downloader]} +
+
+ )} +
+ +
+ +
+
+
+
+ ); +} export interface DownloadGroupProps { library: LibraryGame[]; @@ -48,32 +449,228 @@ export function DownloadGroup({ openGameInstaller, seedingStatus, }: Readonly) { - const navigate = useNavigate(); - const { t } = useTranslation("downloads"); + const navigate = useNavigate(); const userPreferences = useAppSelector( (state) => state.userPreferences.value ); + const extraction = useAppSelector((state) => state.download.extraction); + const { updateLibrary } = useLibrary(); const { lastPacket, - progress, - pauseDownload, - resumeDownload, + pauseDownload: pauseDownloadOriginal, + resumeDownload: resumeDownloadOriginal, cancelDownload, isGameDeleting, pauseSeeding, resumeSeeding, } = useDownload(); + // Wrap resumeDownload with optimistic update + const resumeDownload = useCallback( + async (shop: GameShop, objectId: string) => { + const gameId = `${shop}:${objectId}`; + + // Optimistically mark as downloading + setOptimisticallyResumed((prev) => ({ ...prev, [gameId]: true })); + + try { + await resumeDownloadOriginal(shop, objectId); + } catch (error) { + // If resume fails, remove optimistic state + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + throw error; + } + }, + [resumeDownloadOriginal] + ); + + // Wrap pauseDownload to clear optimistic state + const pauseDownload = useCallback( + async (shop: GameShop, objectId: string) => { + const gameId = `${shop}:${objectId}`; + + // Clear optimistic state when pausing + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + + await pauseDownloadOriginal(shop, objectId); + }, + [pauseDownloadOriginal] + ); + + const { formatDistance } = useDate(); + + const [peakSpeeds, setPeakSpeeds] = useState>({}); + const speedHistoryRef = useRef>({}); + const [dominantColors, setDominantColors] = useState>( + {} + ); + const [optimisticallyResumed, setOptimisticallyResumed] = useState< + Record + >({}); + + const extractDominantColor = useCallback( + async (imageUrl: string, gameId: string) => { + if (dominantColors[gameId]) return; + + try { + const color = await average(imageUrl, { amount: 1, format: "hex" }); + const colorString = + typeof color === "string" ? color : color.toString(); + setDominantColors((prev) => ({ ...prev, [gameId]: colorString })); + } catch (error) { + console.error("Failed to extract dominant color:", error); + } + }, + [dominantColors] + ); + + // Clear optimistic state when actual download starts or library updates + useEffect(() => { + if (lastPacket?.gameId) { + const gameId = lastPacket.gameId; + + // Clear optimistic state when actual download starts + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + } + }, [lastPacket?.gameId]); + + // Clear optimistic state for games that are no longer active after library update + useEffect(() => { + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + let changed = false; + + for (const gameId in next) { + if (next[gameId]) { + const game = library.find((g) => g.id === gameId); + // Clear if game doesn't exist or download status is not active + if ( + !game || + game.download?.status !== "active" || + lastPacket?.gameId === gameId + ) { + delete next[gameId]; + changed = true; + } + } + } + + return changed ? next : prev; + }); + }, [library, lastPacket?.gameId]); + + useEffect(() => { + if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) { + const gameId = lastPacket.gameId; + + const currentPeak = peakSpeeds[gameId] || 0; + if (lastPacket.downloadSpeed > currentPeak) { + setPeakSpeeds((prev) => ({ + ...prev, + [gameId]: lastPacket.downloadSpeed, + })); + } + + if (!speedHistoryRef.current[gameId]) { + speedHistoryRef.current[gameId] = []; + } + + speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed); + + if (speedHistoryRef.current[gameId].length > 120) { + speedHistoryRef.current[gameId].shift(); + } + } + }, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]); + + useEffect(() => { + for (const game of library) { + if ( + game.download && + game.download.progress < 0.01 && + game.download.status !== "paused" + ) { + // Fresh download - clear any old data + if (speedHistoryRef.current[game.id]?.length > 0) { + speedHistoryRef.current[game.id] = []; + setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 })); + } + } + } + }, [library]); + + useEffect(() => { + const timeouts: NodeJS.Timeout[] = []; + + for (const game of library) { + if ( + game.download?.progress === 1 && + speedHistoryRef.current[game.id]?.length > 0 + ) { + const timeout = setTimeout(() => { + speedHistoryRef.current[game.id] = []; + setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 })); + }, 10_000); + timeouts.push(timeout); + } + } + + return () => { + for (const timeout of timeouts) { + clearTimeout(timeout); + } + }; + }, [library]); + + useEffect(() => { + if (library.length > 0 && title === t("download_in_progress")) { + const game = library[0]; + const heroImageUrl = + game.libraryHeroImageUrl || game.libraryImageUrl || ""; + if (heroImageUrl && game.id) { + extractDominantColor(heroImageUrl, game.id); + } + } + }, [library, title, t, extractDominantColor]); + + const isGameSeeding = (game: LibraryGame) => { + const entry = seedingStatus.find((s) => s.gameId === game.id); + if (entry?.status) return entry.status === "seeding"; + return game.download?.status === "seeding"; + }; + + const isGameDownloadingMap = useMemo(() => { + const map: Record = {}; + for (const game of library) { + map[game.id] = + lastPacket?.gameId === game.id || + optimisticallyResumed[game.id] === true; + } + return map; + }, [library, lastPacket?.gameId, optimisticallyResumed]); + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; - if (download.fileSize) return formatBytes(download.fileSize); + if (download.fileSize != null) return formatBytes(download.fileSize); if (lastPacket?.download.fileSize && isGameDownloading) return formatBytes(lastPacket.download.fileSize); @@ -81,15 +678,27 @@ export function DownloadGroup({ return "N/A"; }; - const seedingMap = useMemo(() => { - const map = new Map(); + const formatSpeed = (speed: number): string => { + return userPreferences?.showDownloadSpeedInMegabytes + ? `${formatBytes(speed)}/s` + : formatBytesToMbps(speed); + }; - seedingStatus.forEach((seed) => { - map.set(seed.gameId, seed); - }); + const calculateETA = () => { + if ( + !lastPacket || + lastPacket.timeRemaining < 0 || + !Number.isFinite(lastPacket.timeRemaining) + ) { + return ""; + } - return map; - }, [seedingStatus]); + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + }; const extractGameDownload = useCallback( async (shop: GameShop, objectId: string) => { @@ -99,110 +708,14 @@ export function DownloadGroup({ [updateLibrary] ); - const getGameInfo = (game: LibraryGame) => { - const download = game.download!; - - const isGameDownloading = lastPacket?.gameId === game.id; - const finalDownloadSize = getFinalDownloadSize(game); - const seedingStatus = seedingMap.get(game.id); - - if (download.extracting) { - return

{t("extracting")}

; - } - - if (isGameDeleting(game.id)) { - return

{t("deleting")}

; - } - - if (isGameDownloading) { - if (lastPacket?.isDownloadingMetadata) { - return

{t("downloading_metadata")}

; - } - - if (lastPacket?.isCheckingFiles) { - return ( - <> -

{progress}

-

{t("checking_files")}

- - ); - } - - return ( - <> -

{progress}

- -

- {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} -

- - {download.downloader === Downloader.Torrent && ( - - {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds - - - )} - - ); - } - - if (download.progress === 1) { - const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0); - - return download.status === "seeding" && - download.downloader === Downloader.Torrent ? ( - <> -

- {t("seeding")} - - -

- {uploadSpeed &&

{uploadSpeed}/s

} - - ) : ( -

{t("completed")}

- ); - } - - if (download.status === "paused") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

{t(download.queued ? "queued" : "paused")}

- - ); - } - - if (download.status === "active") { - return ( - <> -

{formatDownloadProgress(download.progress)}

- -

- {formatBytes(download.bytesDownloaded)} / {finalDownloadSize} -

- - ); - } - - return

{t(download.status as string)}

; - }; - const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const deleting = isGameDeleting(game.id); if (game.download?.progress === 1) { - return [ + const actions = [ { label: t("install"), disabled: deleting, @@ -224,7 +737,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status === "seeding" && + isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { pauseSeeding(game.shop, game.objectId); @@ -235,7 +748,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status !== "seeding" && + !isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { resumeSeeding(game.shop, game.objectId); @@ -250,6 +763,7 @@ export function DownloadGroup({ }, }, ]; + return actions.filter((action) => action.show !== false); } if (isGameDownloading) { @@ -296,80 +810,174 @@ export function DownloadGroup({ ]; }; + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [ + library, + lastPacket?.gameId, + lastPacket?.download.fileSize, + isGameDownloadingMap, + seedingStatus, + ] + ); + if (!library.length) return null; + const isDownloadingGroup = title === t("download_in_progress"); + const isQueuedGroup = title === t("queued_downloads"); + const isCompletedGroup = title === t("downloads_completed"); + + if (isDownloadingGroup && library.length > 0) { + const game = library[0]; + const isGameExtracting = extraction?.visibleId === game.id; + const isGameDownloading = + isGameDownloadingMap[game.id] && !isGameExtracting; + const downloadSpeed = isGameDownloading + ? (lastPacket?.downloadSpeed ?? 0) + : 0; + const finalDownloadSize = getFinalDownloadSize(game); + const peakSpeed = peakSpeeds[game.id] || 0; + + let currentProgress = game.download?.progress || 0; + if (isGameExtracting) { + currentProgress = extraction.progress; + } else if (isGameDownloading && lastPacket) { + currentProgress = lastPacket.progress; + } + + const dominantColor = dominantColors[game.id] || "#fff"; + + return ( + + ); + } + return ( -
+
-

{title}

-
-

{library.length}

+
+

{title}

+

{library.length}

+
-
    - {library.map((game) => { +
      + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { return ( -
    • -
      -
      - {game.title} +
    • + -
      +
      + +
      +
      {DOWNLOADER_NAME[game.download!.downloader]}
      -
      -
      -
      -
      -
      - +
      + {extraction?.visibleId === game.id ? ( + + {t("extracting")} ( + {Math.round(extraction.progress * 100)}%) + + ) : ( + + + {size} + + )} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )}
      - - {getGameInfo(game)}
      - - {getGameActions(game) !== null && ( - - - - )}
      - {game.download?.downloader === Downloader.Hydra && ( -
      + {isQueuedGroup && ( +
      + + {formatDownloadProgress(progress)} + +
      +
      +
      +
      )} + +
      + {game.download?.progress === 1 && ( + + )} + {isQueuedGroup && game.download?.progress !== 1 && ( + + )} + + + +
    • ); })} diff --git a/src/renderer/src/pages/downloads/downloads.scss b/src/renderer/src/pages/downloads/downloads.scss index 8290a66e..abada8d7 100644 --- a/src/renderer/src/pages/downloads/downloads.scss +++ b/src/renderer/src/pages/downloads/downloads.scss @@ -3,7 +3,6 @@ .downloads { &__container { display: flex; - padding: calc(globals.$spacing-unit * 3); flex-direction: column; width: 100%; } diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index c222ab65..10d817f1 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { useDownload, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; @@ -13,6 +13,7 @@ import { ArrowDownIcon } from "@primer/octicons-react"; export default function Downloads() { const { library, updateLibrary } = useLibrary(); + const extraction = useAppSelector((state) => state.download.extraction); const { t } = useTranslation("downloads"); @@ -39,11 +40,13 @@ export default function Downloads() { useEffect(() => { window.electron.onSeedingStatus((value) => setSeedingStatus(value)); - const unsubscribe = window.electron.onExtractionComplete(() => { + const unsubscribeExtraction = window.electron.onExtractionComplete(() => { updateLibrary(); }); - return () => unsubscribe(); + return () => { + unsubscribeExtraction(); + }; }, [updateLibrary]); const handleOpenGameInstaller = (shop: GameShop, objectId: string) => @@ -72,8 +75,10 @@ export default function Downloads() { /* Game has been manually added to the library */ if (!next.download) return prev; - /* Is downloading */ - if (lastPacket?.gameId === next.id || next.download.extracting) + /* Is downloading or extracting */ + const isExtracting = + next.download.extracting || extraction?.visibleId === next.id; + if (lastPacket?.gameId === next.id || isExtracting) return { ...prev, downloading: [...prev.downloading, next] }; /* Is either queued or paused */ @@ -96,7 +101,7 @@ export default function Downloads() { queued, complete, }; - }, [library, lastPacket?.gameId]); + }, [library, lastPacket?.gameId, extraction?.visibleId]); const downloadGroups = [ { diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx index c9658636..e19cbf26 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx @@ -8,6 +8,7 @@ import { import useEmblaCarousel from "embla-carousel-react"; import { gameDetailsContext } from "@renderer/context"; import { useAppSelector } from "@renderer/hooks"; +import { VideoPlayer } from "./video-player"; import "./gallery-slider.scss"; export function GallerySlider() { @@ -100,20 +101,44 @@ export function GallerySlider() { src?: string; poster?: string; videoSrc?: string; + videoType?: string; alt: string; }> = []; if (shopDetails?.movies) { shopDetails.movies.forEach((video, index) => { - items.push({ - id: String(video.id), - type: "video", - poster: video.thumbnail, - videoSrc: video.mp4.max.startsWith("http://") - ? video.mp4.max.replace("http://", "https://") - : video.mp4.max, - alt: t("video", { number: String(index + 1) }), - }); + let videoSrc: string | undefined; + let videoType: string | undefined; + + if (video.hls_h264) { + videoSrc = video.hls_h264; + videoType = "application/x-mpegURL"; + } else if (video.dash_h264) { + videoSrc = video.dash_h264; + videoType = "application/dash+xml"; + } else if (video.dash_av1) { + videoSrc = video.dash_av1; + videoType = "application/dash+xml"; + } else if (video.mp4?.max) { + videoSrc = video.mp4.max; + videoType = "video/mp4"; + } else if (video.webm?.max) { + videoSrc = video.webm.max; + videoType = "video/webm"; + } + + if (videoSrc) { + items.push({ + id: String(video.id), + type: "video", + poster: video.thumbnail, + videoSrc: videoSrc.startsWith("http://") + ? videoSrc.replace("http://", "https://") + : videoSrc, + videoType, + alt: video.name || t("video", { number: String(index + 1) }), + }); + } }); } @@ -163,17 +188,17 @@ export function GallerySlider() { {mediaItems.map((item) => (
      {item.type === "video" ? ( - + /> ) : ( (null); + const isHls = videoType === "application/x-mpegURL"; + + useHlsVideo(videoRef, { + videoSrc, + videoType, + autoplay, + muted, + loop, + }); + + if (isHls) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 270ed030..24c37b18 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -1,7 +1,12 @@ import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { formatDownloadProgress } from "@renderer/helpers"; -import { useDate, useDownload, useFormat } from "@renderer/hooks"; +import { + useAppSelector, + useDate, + useDownload, + useFormat, +} from "@renderer/hooks"; import { Link } from "@renderer/components"; import { gameDetailsContext } from "@renderer/context"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; @@ -17,6 +22,9 @@ export function HeroPanelPlaytime() { const { numberFormatter } = useFormat(); const { progress, lastPacket } = useDownload(); const { formatDistance } = useDate(); + const extraction = useAppSelector((state) => state.download.extraction); + + const isExtracting = extraction?.visibleId === game?.id; useEffect(() => { if (game?.lastTimePlayed) { @@ -52,6 +60,16 @@ export function HeroPanelPlaytime() { const isGameDownloading = game.download?.status === "active" && lastPacket?.gameId === game.id; + const extractionInProgressInfo = ( +
      + + {t("extracting")} + + + {formatDownloadProgress(extraction?.progress ?? 0)} +
      + ); + const downloadInProgressInfo = (
      @@ -72,7 +90,8 @@ export function HeroPanelPlaytime() { return ( <>

      {t("not_played_yet", { title: game?.title })}

      - {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -81,7 +100,8 @@ export function HeroPanelPlaytime() { return ( <>

      {t("playing_now")}

      - {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -113,9 +133,9 @@ export function HeroPanelPlaytime() { })}

      - {hasDownload ? ( - downloadInProgressInfo - ) : ( + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} + {!isExtracting && !hasDownload && (

      {t("last_time_played", { period: lastTimePlayed, diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss index c91e685c..6aa4d311 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -80,5 +80,11 @@ &--disabled { opacity: globals.$disabled-opacity; } + + &--extraction { + &::-webkit-progress-value { + background-color: #fff; + } + } } } diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 799f2c36..48cda106 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { useTranslation } from "react-i18next"; -import { useDate, useDownload } from "@renderer/hooks"; +import { useAppSelector, useDate, useDownload } from "@renderer/hooks"; import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelPlaytime } from "./hero-panel-playtime"; @@ -18,9 +18,13 @@ export function HeroPanel() { const { lastPacket } = useDownload(); + const extraction = useAppSelector((state) => state.download.extraction); + const isGameDownloading = game?.download?.status === "active" && lastPacket?.gameId === game?.id; + const isExtracting = extraction?.visibleId === game?.id; + const getInfo = () => { if (!game) { const [latestRepack] = repacks; @@ -49,6 +53,8 @@ export function HeroPanel() { (game?.download?.status === "active" && game?.download?.progress < 1) || game?.download?.status === "paused"; + const showExtractionProgressBar = isExtracting; + return (

      @@ -72,6 +78,14 @@ export function HeroPanel() { }`} /> )} + + {showExtractionProgressBar && ( + + )}
      ); diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index e658fbb8..387c2356 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -1,7 +1,7 @@ import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, CheckboxField, Modal, TextField } from "@renderer/components"; -import type { LibraryGame, ShortcutLocation } from "@types"; +import type { Game, LibraryGame, ShortcutLocation } from "@types"; import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; @@ -11,6 +11,8 @@ import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { debounce } from "lodash-es"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { getGameKey } from "@renderer/helpers"; import "./game-options-modal.scss"; import { logger } from "@renderer/logger"; @@ -75,11 +77,19 @@ export function GameOptionsModal({ const debounceUpdateLaunchOptions = useRef( debounce(async (value: string) => { - await window.electron.updateLaunchOptions( - game.shop, - game.objectId, - value - ); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const trimmedValue = value.trim(); + const updated = { + ...gameData, + launchOptions: trimmedValue ? trimmedValue : null, + }; + await levelDBService.put(gameKey, updated, "games"); + } updateGame(); }, 1000) ).current; @@ -213,9 +223,16 @@ export function GameOptionsModal({ const handleClearLaunchOptions = async () => { setLaunchOptions(""); - window.electron - .updateLaunchOptions(game.shop, game.objectId, null) - .then(updateGame); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const updated = { ...gameData, launchOptions: null }; + await levelDBService.put(gameKey, updated, "games"); + } + updateGame(); }; const shouldShowWinePrefixConfiguration = @@ -256,11 +273,15 @@ export function GameOptionsModal({ ) => { setAutomaticCloudSync(event.target.checked); - await window.electron.toggleAutomaticCloudSync( - game.shop, - game.objectId, - event.target.checked - ); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const updated = { ...gameData, automaticCloudSync: event.target.checked }; + await levelDBService.put(gameKey, updated, "games"); + } updateGame(); }; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 91013da0..08efb014 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,7 +15,7 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import type { DownloadSource, GameRepack } from "@types"; +import type { DownloadSource, Game, GameRepack } from "@types"; import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; @@ -23,6 +23,8 @@ import { Downloader } from "@shared"; import { orderBy } from "lodash-es"; import { useDate, useFeature, useAppDispatch } from "@renderer/hooks"; import { clearNewDownloadOptions } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { getGameKey } from "@renderer/helpers"; import "./repacks-modal.scss"; export interface RepacksModalProps { @@ -98,8 +100,11 @@ export function RepacksModal({ useEffect(() => { const fetchDownloadSources = async () => { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); }; fetchDownloadSources(); @@ -109,10 +114,19 @@ export function RepacksModal({ const fetchLastCheckTimestamp = async () => { setIsLoadingTimestamp(true); - const timestamp = await window.electron.getDownloadSourcesSinceValue(); + try { + const timestamp = (await levelDBService.get( + "downloadSourcesSinceValue", + null, + "utf8" + )) as string | null; - setLastCheckTimestamp(timestamp); - setIsLoadingTimestamp(false); + setLastCheckTimestamp(timestamp); + } catch { + setLastCheckTimestamp(null); + } finally { + setIsLoadingTimestamp(false); + } }; if (visible) { @@ -126,7 +140,20 @@ export function RepacksModal({ game?.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 ) { - globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); + const gameKey = getGameKey(game.shop, game.objectId); + levelDBService + .get(gameKey, "games") + .then((gameData) => { + if (gameData) { + const updated = { + ...(gameData as Game), + newDownloadOptionsCount: undefined, + }; + return levelDBService.put(gameKey, updated, "games"); + } + return Promise.resolve(); + }) + .catch(() => {}); const gameId = `${game.shop}:${game.objectId}`; dispatch(clearNewDownloadOptions({ gameId })); @@ -204,9 +231,19 @@ export function RepacksModal({ return false; } - const lastCheckUtc = new Date(lastCheckTimestamp).toISOString(); + try { + const lastCheckDate = new Date(lastCheckTimestamp); - return repack.createdAt > lastCheckUtc; + if (isNaN(lastCheckDate.getTime())) { + return false; + } + + const lastCheckUtc = lastCheckDate.toISOString(); + + return repack.createdAt > lastCheckUtc; + } catch { + return false; + } }; const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index b8f632a6..91c9b2ff 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import { useNavigate } from "react-router-dom"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import { Button, GameCard, Hero } from "@renderer/components"; -import type { ShopAssets, Steam250Game } from "@types"; +import type { DownloadSource, ShopAssets, Steam250Game } from "@types"; import flameIconStatic from "@renderer/assets/icons/flame-static.png"; import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif"; @@ -40,7 +42,10 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const downloadSources = await window.electron.getDownloadSources(); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const downloadSources = orderBy(sources, "createdAt", "desc"); const params = { take: 12, diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 292290ca..dd998c59 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,7 +1,7 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; -import { memo, useMemo } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import "./library-game-card-large.scss"; interface LibraryGameCardLargeProps { @@ -48,6 +48,22 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ ] ); + const [unlockedAchievementsCount, setUnlockedAchievementsCount] = useState( + game.unlockedAchievementCount ?? 0 + ); + + useEffect(() => { + if (game.unlockedAchievementCount) return; + + window.electron + .getUnlockedAchievements(game.objectId, game.shop) + .then((achievements) => { + setUnlockedAchievementsCount( + achievements.filter((a) => a.unlocked).length + ); + }); + }, [game]); + const backgroundStyle = useMemo( () => backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {}, @@ -56,9 +72,9 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ const achievementBarStyle = useMemo( () => ({ - width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`, + width: `${(unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100}%`, }), - [game.unlockedAchievementCount, game.achievementCount] + [unlockedAchievementsCount, game.achievementCount] ); const logoImage = game.customLogoImageUrl ?? game.logoImageUrl; @@ -116,14 +132,12 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ className="library-game-card-large__achievement-trophy" /> - {game.unlockedAchievementCount ?? 0} /{" "} - {game.achievementCount ?? 0} + {unlockedAchievementsCount} / {game.achievementCount ?? 0}
      {Math.round( - ((game.unlockedAchievementCount ?? 0) / - (game.achievementCount ?? 1)) * + (unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100 )} % diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 8b377f63..48d7e2f7 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -14,10 +14,6 @@ import "./library.scss"; export default function Library() { const { library, updateLibrary } = useLibrary(); - type ElectronAPI = { - refreshLibraryAssets?: () => Promise; - onLibraryBatchComplete?: (cb: () => void) => () => void; - }; const [viewMode, setViewMode] = useState(() => { const savedViewMode = localStorage.getItem("library-view-mode"); @@ -41,22 +37,15 @@ export default function Library() { useEffect(() => { dispatch(setHeaderTitle(t("library"))); - const electron = (globalThis as unknown as { electron?: ElectronAPI }) - .electron; - let unsubscribe: () => void = () => undefined; - if (electron?.refreshLibraryAssets) { - electron - .refreshLibraryAssets() - .then(() => updateLibrary()) - .catch(() => updateLibrary()); - if (electron.onLibraryBatchComplete) { - unsubscribe = electron.onLibraryBatchComplete(() => { - updateLibrary(); - }); - } - } else { + + const unsubscribe = window.electron.onLibraryBatchComplete(() => { updateLibrary(); - } + }); + + window.electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); return () => { unsubscribe(); @@ -87,7 +76,13 @@ export default function Library() { switch (filterBy) { case "recently_played": - filtered = library.filter((game) => game.lastTimePlayed !== null); + filtered = library + .filter((game) => game.lastTimePlayed !== null) + .sort( + (a: any, b: any) => + new Date(b.lastTimePlayed).getTime() - + new Date(a.lastTimePlayed).getTime() + ); break; case "favorites": filtered = library.filter((game) => game.favorite); diff --git a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx index 68eed4c3..eb0ca287 100644 --- a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx +++ b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { DeleteThemeModal } from "../modals/delete-theme-modal"; import { injectCustomCss, removeCustomCss } from "@renderer/helpers"; import { THEME_WEB_STORE_URL } from "@renderer/constants"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface ThemeCardProps { theme: Theme; @@ -22,11 +23,18 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => { const handleSetTheme = async () => { try { - const currentTheme = await window.electron.getCustomThemeById(theme.id); + const currentTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (!currentTheme) return; - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + }[]; + const activeTheme = allThemes.find((t) => t.isActive); if (activeTheme) { removeCustomCss(); diff --git a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx index 522d8546..c8a2c80d 100644 --- a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx @@ -10,6 +10,7 @@ import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { useCallback } from "react"; import { generateUUID } from "@renderer/helpers"; +import { levelDBService } from "@renderer/services/leveldb.service"; import "./modals.scss"; @@ -90,7 +91,7 @@ export function AddThemeModal({ updatedAt: new Date(), }; - await window.electron.addCustomTheme(theme); + await levelDBService.put(theme.id, theme, "themes"); onThemeAdded(); onClose(); reset(); diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx index 9439d273..fa21bc2c 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -3,6 +3,7 @@ import { Modal } from "@renderer/components/modal/modal"; import { useTranslation } from "react-i18next"; import "./modals.scss"; import { removeCustomCss } from "@renderer/helpers"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface DeleteAllThemesModalProps { visible: boolean; @@ -18,13 +19,16 @@ export const DeleteAllThemesModal = ({ const { t } = useTranslation("settings"); const handleDeleteAllThemes = async () => { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme) { removeCustomCss(); } - await window.electron.deleteAllCustomThemes(); + await levelDBService.clear("themes"); await window.electron.closeEditorWindow(); onClose(); onThemesDeleted(); diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx index c1a5a1e0..d2158f6f 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -3,6 +3,7 @@ import { Modal } from "@renderer/components/modal/modal"; import { useTranslation } from "react-i18next"; import "./modals.scss"; import { removeCustomCss } from "@renderer/helpers"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface DeleteThemeModalProps { visible: boolean; @@ -28,7 +29,7 @@ export const DeleteThemeModal = ({ removeCustomCss(); } - await window.electron.deleteCustomTheme(themeId); + await levelDBService.del(themeId, "themes"); await window.electron.closeEditorWindow(themeId); onThemeDeleted(); }; diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index 93baf1cd..e729ae29 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -11,6 +11,7 @@ import { import { useToast } from "@renderer/hooks"; import { THEME_WEB_STORE_URL } from "@renderer/constants"; import { logger } from "@renderer/logger"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface ImportThemeModalProps { visible: boolean; @@ -45,9 +46,12 @@ export const ImportThemeModal = ({ }; try { - await window.electron.addCustomTheme(theme); + await levelDBService.put(theme.id, theme, "themes"); - const currentTheme = await window.electron.getCustomThemeById(theme.id); + const currentTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (!currentTheme) return; @@ -61,7 +65,11 @@ export const ImportThemeModal = ({ logger.error("Failed to import theme sound", soundError); } - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + }[]; + const activeTheme = allThemes.find((t) => t.isActive); if (activeTheme) { removeCustomCss(); diff --git a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx index 413e52e9..24247396 100644 --- a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx +++ b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx @@ -5,6 +5,7 @@ import type { Theme } from "@types"; import { ImportThemeModal } from "./modals/import-theme-modal"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface SettingsAppearanceProps { appearance: { @@ -31,7 +32,7 @@ export function SettingsAppearance({ const navigate = useNavigate(); const loadThemes = useCallback(async () => { - const themesList = await window.electron.getAllCustomThemes(); + const themesList = (await levelDBService.values("themes")) as Theme[]; setThemes(themesList); }, []); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index 675919e3..f597838e 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -21,6 +21,8 @@ import { DownloadSourceStatus } from "@shared"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; import { setFilters, clearFilters } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import "./settings-download-sources.scss"; import { logger } from "@renderer/logger"; @@ -52,8 +54,11 @@ export function SettingsDownloadSources() { useEffect(() => { const fetchDownloadSources = async () => { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); }; fetchDownloadSources(); @@ -73,8 +78,11 @@ export function SettingsDownloadSources() { const intervalId = setInterval(async () => { try { await window.electron.syncDownloadSources(); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); } catch (error) { logger.error("Failed to fetch download sources:", error); } @@ -88,8 +96,11 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(false, downloadSource.id); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("removed_download_source")); } catch (error) { logger.error("Failed to remove download source:", error); @@ -103,8 +114,11 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(true); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("removed_all_download_sources")); } catch (error) { logger.error("Failed to remove all download sources:", error); @@ -116,8 +130,11 @@ export function SettingsDownloadSources() { const handleAddDownloadSource = async () => { try { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); } catch (error) { logger.error("Failed to refresh download sources:", error); } @@ -127,8 +144,11 @@ export function SettingsDownloadSources() { setIsSyncingDownloadSources(true); try { await window.electron.syncDownloadSources(); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("download_sources_synced_successfully")); } finally { diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 3f0be9cf..41dc7a7f 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -16,6 +16,7 @@ import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; +import { levelDBService } from "@renderer/services/leveldb.service"; import app from "../../app.scss?inline"; import styles from "../../components/achievements/notification/achievement-notification.scss?inline"; import root from "react-shadow"; @@ -64,15 +65,16 @@ export default function ThemeEditor() { useEffect(() => { if (themeId) { - window.electron.getCustomThemeById(themeId).then((loadedTheme) => { - if (loadedTheme) { - setTheme(loadedTheme); - setCode(loadedTheme.code); - if (loadedTheme.originalSoundPath) { - setSoundPath(loadedTheme.originalSoundPath); + levelDBService.get(themeId, "themes").then((loadedTheme) => { + const theme = loadedTheme as Theme | null; + if (theme) { + setTheme(theme); + setCode(theme.code); + if (theme.originalSoundPath) { + setSoundPath(theme.originalSoundPath); } if (shadowRootRef) { - injectCustomCss(loadedTheme.code, shadowRootRef); + injectCustomCss(theme.code, shadowRootRef); } } }); @@ -132,7 +134,10 @@ export default function ThemeEditor() { if (filePaths && filePaths.length > 0) { const originalPath = filePaths[0]; await window.electron.copyThemeAchievementSound(theme.id, originalPath); - const updatedTheme = await window.electron.getCustomThemeById(theme.id); + const updatedTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (updatedTheme) { setTheme(updatedTheme); if (updatedTheme.originalSoundPath) { @@ -146,7 +151,10 @@ export default function ThemeEditor() { if (!theme) return; await window.electron.removeThemeAchievementSound(theme.id); - const updatedTheme = await window.electron.getCustomThemeById(theme.id); + const updatedTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (updatedTheme) { setTheme(updatedTheme); } diff --git a/src/renderer/src/services/leveldb.service.ts b/src/renderer/src/services/leveldb.service.ts new file mode 100644 index 00000000..68e5e9f1 --- /dev/null +++ b/src/renderer/src/services/leveldb.service.ts @@ -0,0 +1,36 @@ +class LevelDBService { + get( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ): Promise { + return window.electron.leveldb.get(key, sublevelName, valueEncoding); + } + + put( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ): Promise { + return window.electron.leveldb.put(key, value, sublevelName, valueEncoding); + } + + del(key: string, sublevelName?: string | null): Promise { + return window.electron.leveldb.del(key, sublevelName); + } + + clear(sublevelName: string): Promise { + return window.electron.leveldb.clear(sublevelName); + } + + values(sublevelName: string): Promise { + return window.electron.leveldb.values(sublevelName); + } + + iterator(sublevelName: string): Promise<[string, unknown][]> { + return window.electron.leveldb.iterator(sublevelName); + } +} + +export const levelDBService = new LevelDBService(); diff --git a/src/types/level.types.ts b/src/types/level.types.ts index fd930a12..c7abaacb 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -20,6 +20,8 @@ export interface Auth { accessToken: string; refreshToken: string; tokenExpirationTimestamp: number; + featurebaseJwt: string; + workwondersJwt: string; } export interface User { @@ -80,6 +82,7 @@ export interface Download { timestamp: number; extracting: boolean; automaticallyExtract: boolean; + extractionProgress: number; } export interface GameAchievement { diff --git a/src/types/steam.types.ts b/src/types/steam.types.ts index 4dcf460a..54164e3c 100644 --- a/src/types/steam.types.ts +++ b/src/types/steam.types.ts @@ -14,10 +14,13 @@ export interface SteamVideoSource { "480": string; } -export interface SteamMovies { +export interface SteamMovie { id: number; - mp4: SteamVideoSource; - webm: SteamVideoSource; + dash_av1?: string; + dash_h264?: string; + hls_h264?: string; + mp4?: SteamVideoSource; + webm?: SteamVideoSource; thumbnail: string; name: string; highlight: boolean; @@ -31,7 +34,7 @@ export interface SteamAppDetails { short_description: string; publishers: string[]; genres: SteamGenre[]; - movies?: SteamMovies[]; + movies?: SteamMovie[]; supported_languages: string; screenshots?: SteamScreenshot[]; pc_requirements: { diff --git a/yarn.lock b/yarn.lock index da346e42..9d354966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5690,6 +5690,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hls.js@^1.5.12: + version "1.6.15" + resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.15.tgz#9ce13080d143a9bc9b903fb43f081e335b8321e5" + integrity sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA== + hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6205,9 +6210,9 @@ jiti@^2.6.1: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" @@ -6325,7 +6330,7 @@ jsonwebtoken@^9.0.2: object.assign "^4.1.4" object.values "^1.1.6" -jwa@^1.4.1: +jwa@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== @@ -6335,11 +6340,11 @@ jwa@^1.4.1: safe-buffer "^5.0.1" jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + version "3.2.3" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1" + integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g== dependencies: - jwa "^1.4.1" + jwa "^1.4.2" safe-buffer "^5.0.1" keyv@^4.0.0, keyv@^4.5.3: @@ -6433,11 +6438,26 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== +lodash.defaultsdeep@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz#512e9bd721d272d94e3d3a63653fa17516741ca6" + integrity sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA== + +lodash.defaultto@^4.14.0: + version "4.14.0" + resolved "https://registry.yarnpkg.com/lodash.defaultto/-/lodash.defaultto-4.14.0.tgz#38bd3d425acee733e0e2bbbd4e4b29711cc2ee11" + integrity sha512-G6tizqH6rg4P5j32Wy4Z3ZIip7OfG8YWWlPFzUFGcYStH1Ld0l1tWs6NevEQNEDnO1M3NZYjuHuraaFSN5WqeQ== + lodash.escaperegexp@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -6448,6 +6468,11 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== +lodash.isempty@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" + integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg== + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" @@ -6488,6 +6513,11 @@ lodash.mergewith@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== +lodash.negate@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/lodash.negate/-/lodash.negate-3.0.2.tgz#9c897b0bf610019e0b43b8ff3f0afef3d7b66f34" + integrity sha512-JGJYYVslKYC0tRMm/7igfdHulCjoXjoganRNWM8AgS+RXfOvFnPkOveDhPI65F9aAypCX9QEEQoBqWf7Q6uAeA== + lodash.once@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" @@ -6867,6 +6897,19 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-7z@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/node-7z/-/node-7z-3.0.0.tgz#42f71c5a43b00028749f7c88291a7abf2e2623e3" + integrity sha512-KIznWSxIkOYO/vOgKQfJEaXd7rgoFYKZbaurainCEdMhYc7V7mRHX+qdf2HgbpQFcdJL/Q6/XOPrDLoBeTfuZA== + dependencies: + debug "^4.3.2" + lodash.defaultsdeep "^4.6.1" + lodash.defaultto "^4.14.0" + lodash.flattendeep "^4.4.0" + lodash.isempty "^4.4.0" + lodash.negate "^3.0.2" + normalize-path "^3.0.0" + node-abi@^3.45.0: version "3.78.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.78.0.tgz#fd0ecbd0aa89857b98da06bd3909194abb0821ba" @@ -6922,6 +6965,11 @@ nopt@^6.0.0: dependencies: abbrev "^1.0.0" +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + normalize-url@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" @@ -8518,10 +8566,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^7.4.3: - version "7.5.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.1.tgz#750a8bd63b7c44c1848e7bf982260a083cf747c9" - integrity sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g== +tar@^7.5.2: + version "7.5.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc" + integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0"