From 94ebf94abce6c7259ca45da02595a509959b1688 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 12 Nov 2025 07:21:28 -0300 Subject: [PATCH 01/58] fix: use local achievement cache for unlocked achievement count --- src/main/events/library/get-library.ts | 12 +++++++++- src/renderer/src/pages/library/library.tsx | 27 +++++++--------------- 2 files changed, 19 insertions(+), 20 deletions(-) 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/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 8b377f63..0efe8fb2 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(); From f84917a00b7d1498648b864518e2a817893530fe Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:07:51 -0300 Subject: [PATCH 02/58] feat: get user static image on notifications --- src/main/events/profile/process-profile-image.ts | 8 ++++++-- src/main/services/notifications/index.ts | 12 ++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts index 6166f7f8..9407f0a2 100644 --- a/src/main/events/profile/process-profile-image.ts +++ b/src/main/events/profile/process-profile-image.ts @@ -1,10 +1,14 @@ 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); +}; + +export const processProfileImage = async (path: string) => { return PythonRPC.rpc .post<{ imagePath: string; @@ -13,4 +17,4 @@ const processProfileImage = async ( .then((response) => response.data); }; -registerEvent("processProfileImage", processProfileImage); +registerEvent("processProfileImage", processProfileImageEvent); diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index b8ff480c..6ad93ea7 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) + .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 }); From c2216bbf95fe5899f87e6a60514994a11cd591c1 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:17:53 -0300 Subject: [PATCH 03/58] feat: use jpg for system notifications --- python_rpc/main.py | 5 ++++- python_rpc/profile_image_processor.py | 8 ++++---- src/main/events/profile/process-profile-image.ts | 6 +++--- src/main/services/notifications/index.ts | 2 +- src/main/services/ws/events/friend-request.ts | 8 +++++--- 5 files changed, 17 insertions(+), 12 deletions(-) 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/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts index 9407f0a2..bec17cb6 100644 --- a/src/main/events/profile/process-profile-image.ts +++ b/src/main/events/profile/process-profile-image.ts @@ -5,15 +5,15 @@ const processProfileImageEvent = async ( _event: Electron.IpcMainInvokeEvent, path: string ) => { - return processProfileImage(path); + return processProfileImage(path, "webp"); }; -export const processProfileImage = async (path: string) => { +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); }; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index 6ad93ea7..a925e7c7 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -18,7 +18,7 @@ import { getThemeSoundPath } from "@main/helpers"; import { processProfileImage } from "@main/events/profile/process-profile-image"; const getStaticImage = async (path: string) => { - return processProfileImage(path) + return processProfileImage(path, "jpg") .then((response) => response.imagePath) .catch(() => path); }; 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); + } } }; From bcd6db24c9040fc44d77f07a0d2ac016cd3b1ed1 Mon Sep 17 00:00:00 2001 From: Kaan Date: Wed, 12 Nov 2025 18:46:18 +0300 Subject: [PATCH 04/58] Add turkish translates for new keys added keys: - sidebar.library - sidebar.playable_button_title - sidebar.add_custom_game_tooltip - sidebar.show_playable_only_tooltip - sidebar.custom_game_modal - sidebar.custom_game_modal_description - sidebar.custom_game_modal_executable_path - sidebar.custom_game_modal_select_executable - sidebar.custom_game_modal_title - sidebar.custom_game_modal_enter_title - sidebar.custom_game_modal_browse - sidebar.custom_game_modal_cancel - sidebar.custom_game_modal_add - sidebar.custom_game_modal_adding - sidebar.custom_game_modal_success - sidebar.custom_game_modal_failed - sidebar.custom_game_modal_executable - sidebar.edit_game_modal - sidebar.edit_game_modal_description - sidebar.edit_game_modal_title - sidebar.edit_game_modal_enter_title - sidebar.edit_game_modal_image - sidebar.edit_game_modal_select_image - sidebar.edit_game_modal_browse - sidebar.edit_game_modal_image_preview - sidebar.edit_game_modal_icon - sidebar.edit_game_modal_select_icon - sidebar.edit_game_modal_icon_preview - sidebar.edit_game_modal_logo - sidebar.edit_game_modal_select_logo - sidebar.edit_game_modal_logo_preview - sidebar.edit_game_modal_hero - sidebar.edit_game_modal_select_hero - sidebar.edit_game_modal_hero_preview - sidebar.edit_game_modal_cancel - sidebar.edit_game_modal_update - sidebar.edit_game_modal_updating - sidebar.edit_game_modal_fill_required - sidebar.edit_game_modal_success - sidebar.edit_game_modal_failed - sidebar.edit_game_modal_image_filter - sidebar.edit_game_modal_icon_resolution - sidebar.edit_game_modal_logo_resolution - sidebar.edit_game_modal_hero_resolution - sidebar.edit_game_modal_assets - sidebar.edit_game_modal_drop_icon_image_here - sidebar.edit_game_modal_drop_logo_image_here - sidebar.edit_game_modal_drop_hero_image_here - sidebar.edit_game_modal_drop_to_replace_icon - sidebar.edit_game_modal_drop_to_replace_logo - sidebar.edit_game_modal_drop_to_replace_hero - sidebar.install_decky_plugin - sidebar.update_decky_plugin - sidebar.decky_plugin_installed_version - sidebar.install_decky_plugin_title - sidebar.install_decky_plugin_message - sidebar.update_decky_plugin_title - sidebar.update_decky_plugin_message - sidebar.decky_plugin_installed - sidebar.decky_plugin_installation_failed - sidebar.decky_plugin_installation_error - sidebar.confirm - sidebar.cancel - header.search_library - header.library - game_details.already_in_library - game_details.create_shortcut_simple - game_details.properties - game_details.new_download_option - game_details.add_to_favorites - game_details.remove_from_favorites - game_details.failed_update_favorites - game_details.game_removed_from_library - game_details.failed_remove_from_library - game_details.files_removed_success - game_details.failed_remove_files - game_details.rating_count - game_details.show_more - game_details.show_less - game_details.reviews - game_details.review_played_for - game_details.leave_a_review - game_details.write_review_placeholder - game_details.sort_newest - game_details.no_reviews_yet - game_details.be_first_to_review - game_details.sort_oldest - game_details.sort_highest_score - game_details.sort_lowest_score - game_details.sort_most_voted - game_details.rating - game_details.rating_stats - game_details.rating_very_negative - game_details.rating_negative - game_details.rating_neutral - game_details.rating_positive - game_details.rating_very_positive - game_details.submit_review - game_details.submitting - game_details.review_submitted_successfully - game_details.review_submission_failed - game_details.review_cannot_be_empty - game_details.review_deleted_successfully - game_details.review_deletion_failed - game_details.loading_reviews - game_details.loading_more_reviews - game_details.load_more_reviews - game_details.you_seemed_to_enjoy_this_game - game_details.would_you_recommend_this_game - game_details.yes - game_details.maybe_later - game_details.backup_failed - game_details.update_playtime_title - game_details.update_playtime_description - game_details.update_playtime - game_details.update_playtime_success - game_details.update_playtime_error - game_details.update_game_playtime - game_details.manual_playtime_warning - game_details.manual_playtime_tooltip - game_details.game_removed_from_pinned - game_details.game_added_to_pinned - game_details.artifact_renamed - game_details.rename_artifact - game_details.rename_artifact_description - game_details.artifact_name_label - game_details.artifact_name_placeholder - game_details.save_changes - game_details.required_field - game_details.max_length_field - game_details.freeze_backup - game_details.unfreeze_backup - game_details.backup_frozen - game_details.backup_unfrozen - game_details.backup_freeze_failed - game_details.backup_freeze_failed_description - game_details.edit_game_modal_button - game_details.game_details - game_details.currency_symbol - game_details.currency_country - game_details.prices - game_details.no_prices_found - game_details.view_all_prices - game_details.retail_price - game_details.keyshop_price - game_details.historical_retail - game_details.historical_keyshop - game_details.language - game_details.caption - game_details.audio - game_details.filter_by_source - game_details.no_repacks_found - game_details.delete_review - game_details.remove_review - game_details.delete_review_modal_title - game_details.delete_review_modal_description - game_details.delete_review_modal_delete_button - game_details.delete_review_modal_cancel_button - game_details.vote_failed - game_details.show_original - game_details.show_translation - game_details.show_original_translated_from - game_details.hide_original - game_details.review_from_blocked_user - game_details.show - game_details.hide - settings.adding - settings.failed_add_download_source - settings.download_source_already_exists - settings.download_source_pending_matching - settings.download_source_matched - settings.download_source_matching - settings.download_source_failed - settings.download_source_no_information - settings.removed_all_download_sources - settings.download_sources_synced_successfully - settings.importing - settings.hydra_cloud - settings.debrid - settings.debrid_description - settings.enable_steam_achievements - settings.achievement_sound_volume - settings.select_achievement_sound - settings.change_achievement_sound - settings.remove_achievement_sound - settings.preview_sound - settings.select - settings.preview - settings.remove - settings.no_sound_file_selected - settings.autoplay_trailers_on_game_page - settings.hide_to_tray_on_game_start - game_card.calculating - user_profile.amount_hours_short - user_profile.amount_minutes_short - user_profile.pinned - user_profile.sort_by - user_profile.achievements_earned - user_profile.played_recently - user_profile.playtime - user_profile.manual_playtime_tooltip - user_profile.error_adding_friend - user_profile.friend_code_length_error - user_profile.game_removed_from_pinned - user_profile.game_added_to_pinned - user_profile.karma - user_profile.karma_count - user_profile.karma_description - user_profile.user_reviews - user_profile.delete_review - user_profile.loading_reviews - library.library - library.play - library.download - library.downloading - library.game - library.games - library.grid_view - library.compact_view - library.large_view - library.no_games_title - library.no_games_description - library.amount_hours - library.amount_minutes - library.amount_hours_short - library.amount_minutes_short - library.manual_playtime_tooltip - library.all_games - library.recently_played - library.favorites --- src/locales/tr/translation.json | 242 +++++++++++++++++++++++++++++++- 1 file changed, 237 insertions(+), 5 deletions(-) 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ı", From 20c0d3174b7f50396bcdcec681abb717c898e333 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:37:44 -0300 Subject: [PATCH 05/58] test --- src/renderer/src/hooks/use-library.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/hooks/use-library.ts b/src/renderer/src/hooks/use-library.ts index f7310df0..0edd29ba 100644 --- a/src/renderer/src/hooks/use-library.ts +++ b/src/renderer/src/hooks/use-library.ts @@ -7,9 +7,25 @@ export function useLibrary() { const library = useAppSelector((state) => state.library.value); const updateLibrary = useCallback(async () => { - return window.electron - .getLibrary() - .then((updatedLibrary) => dispatch(setLibrary(updatedLibrary))); + return window.electron.getLibrary().then(async (updatedLibrary) => { + const libraryWithAchievements = await Promise.all( + updatedLibrary.map(async (game) => { + const unlockedAchievements = + await window.electron.getUnlockedAchievements( + game.objectId, + game.shop + ); + + return { + ...game, + unlockedAchievementCount: + game.unlockedAchievementCount || unlockedAchievements.length, + }; + }) + ); + + dispatch(setLibrary(libraryWithAchievements)); + }); }, [dispatch]); return { library, updateLibrary }; From f594cd298afd470f76df5bf5f6d032e936ccc9e6 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:52:38 -0300 Subject: [PATCH 06/58] fix: performance --- src/renderer/src/hooks/use-library.ts | 22 +++------------- .../pages/library/library-game-card-large.tsx | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/hooks/use-library.ts b/src/renderer/src/hooks/use-library.ts index 0edd29ba..f7310df0 100644 --- a/src/renderer/src/hooks/use-library.ts +++ b/src/renderer/src/hooks/use-library.ts @@ -7,25 +7,9 @@ export function useLibrary() { const library = useAppSelector((state) => state.library.value); const updateLibrary = useCallback(async () => { - return window.electron.getLibrary().then(async (updatedLibrary) => { - const libraryWithAchievements = await Promise.all( - updatedLibrary.map(async (game) => { - const unlockedAchievements = - await window.electron.getUnlockedAchievements( - game.objectId, - game.shop - ); - - return { - ...game, - unlockedAchievementCount: - game.unlockedAchievementCount || unlockedAchievements.length, - }; - }) - ); - - dispatch(setLibrary(libraryWithAchievements)); - }); + return window.electron + .getLibrary() + .then((updatedLibrary) => dispatch(setLibrary(updatedLibrary))); }, [dispatch]); return { library, updateLibrary }; 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..9aba4b08 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,20 @@ 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.length); + }); + }, [game]); + const backgroundStyle = useMemo( () => backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {}, @@ -56,9 +70,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 +130,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 )} % From c600a4a46f0a4472b6e5a7a7bc1856be07799a3d Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Thu, 13 Nov 2025 15:22:03 +0000 Subject: [PATCH 07/58] fix: fixing achievements on larger view --- src/renderer/src/pages/library/library-game-card-large.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 9aba4b08..dd998c59 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -58,7 +58,9 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ window.electron .getUnlockedAchievements(game.objectId, game.shop) .then((achievements) => { - setUnlockedAchievementsCount(achievements.length); + setUnlockedAchievementsCount( + achievements.filter((a) => a.unlocked).length + ); }); }, [game]); From 83fbf203839e223cf61640ccea608283acbd0638 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Fri, 14 Nov 2025 20:02:10 +0530 Subject: [PATCH 08/58] feat: enhance download page UI with improved layout and styling for cards --- .../src/pages/downloads/download-group.scss | 319 +++++++++---- .../src/pages/downloads/download-group.tsx | 442 +++++++++++------- 2 files changed, 511 insertions(+), 250 deletions(-) diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b..22bff527 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -5,14 +5,6 @@ flex-direction: column; gap: calc(globals.$spacing-unit * 2); - &__details-with-article { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit / 2); - align-self: flex-start; - cursor: pointer; - } - &__header { display: flex; align-items: center; @@ -30,29 +22,9 @@ } } - &__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 { width: 100%; - gap: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit * 3); display: flex; flex-direction: column; margin: 0; @@ -67,86 +39,259 @@ border-radius: 8px; border: solid 1px globals.$border-color; overflow: hidden; - box-shadow: 0px 0px 5px 0px #000000; + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.5); transition: all ease 0.2s; - height: 140px; - min-height: 140px; - max-height: 140px; + height: 250px; + min-height: 250px; + max-height: 250px; position: relative; + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover { + transform: scale(1.01); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + &--hydra { box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); } } - &__cover { - width: 280px; - min-width: 280px; - height: auto; - border-right: solid 1px globals.$border-color; + + &__background-image { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 25%; + } + } + + &__background-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 130deg, + rgba(0, 0, 0, 0.2) 0%, + rgba(0, 0, 0, 0.5) 50%, + rgba(0, 0, 0, 0.8) 100% + ); + } + + &__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 { + z-index: 2; + width: 100%; + height: 100%; 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 { + &__left-section { + flex: 1; + max-width: 50%; + height: 100%; + display: flex; + align-items: flex-end; + padding: calc(globals.$spacing-unit * 2); + } + + &__logo-container { display: flex; flex-direction: column; - flex: 1; - justify-content: center; - gap: calc(globals.$spacing-unit / 2); - font-size: 14px; + gap: globals.$spacing-unit; } - &__actions { + &__logo { + max-width: 350px; + max-height: 150px; + object-fit: contain; + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8)); + } + + &__game-title { + font-size: 24px; + font-weight: 700; + color: #ffffff; + text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9); + margin: 0; + } + + &__downloader-badge { + align-self: flex-start; + } + + &__right-section { + flex: 1; + max-width: 50%; + display: flex; + flex-direction: column; + padding: calc(globals.$spacing-unit * 2); + position: relative; + justify-content: space-between; + } + + &__top-row { display: flex; align-items: center; - gap: globals.$spacing-unit; + justify-content: space-between; + gap: calc(globals.$spacing-unit * 2); + } + + &__stats { + display: flex; + gap: calc(globals.$spacing-unit * 3); + } + + &__stat { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + + svg { + opacity: 0.8; + flex-shrink: 0; + } + } + + &__stat-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__stat-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + line-height: 1; + } + + &__stat-value { + color: #ffffff; + font-weight: 700; + font-size: 14px; + line-height: 1.2; } &__menu-button { - position: absolute; - top: 12px; - right: 12px; - border-radius: 50%; border: none; padding: 8px; min-height: unset; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + flex-shrink: 0; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + } + } + + &__progress-section { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + flex: 1; + } + + &__bottom-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + } + + &__progress-info { + display: flex; + justify-content: space-between; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + } + + &__progress-text { + font-weight: 600; + } + + &__progress-size { + color: globals.$muted-color; + } + + &__progress-bar { + width: 100%; + height: 6px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + position: relative; + } + + &__progress-fill { + height: 100%; + background-color: globals.$muted-color; + transition: width 0.3s ease; + border-radius: 4px; + } + + &__time-remaining { + font-size: 11px; + color: globals.$muted-color; + text-align: left; + min-height: 16px; + } + + &__quick-actions { + display: flex; + flex-shrink: 0; + min-height: 40px; + align-items: center; + } + + &__action-btn { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2); + font-size: 13px; + font-weight: 600; + + svg { + width: 14px; + height: 14px; + } } &__hydra-gradient { @@ -156,6 +301,6 @@ position: absolute; bottom: 0; height: 2px; - z-index: 1; + z-index: 2; } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..9f999317 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,21 +1,18 @@ -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, -} from "@renderer/helpers"; +import { formatDownloadProgress } from "@renderer/helpers"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, formatBytesToMbps } from "@shared"; +import { formatDistance, addMilliseconds } from "date-fns"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { DropdownMenu, DropdownMenuItem, @@ -26,11 +23,12 @@ import { FileDirectoryIcon, LinkIcon, PlayIcon, - QuestionIcon, ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, + DatabaseIcon, + GraphIcon, } from "@primer/octicons-react"; export interface DownloadGroupProps { @@ -48,8 +46,6 @@ export function DownloadGroup({ openGameInstaller, seedingStatus, }: Readonly) { - const navigate = useNavigate(); - const { t } = useTranslation("downloads"); const userPreferences = useAppSelector( @@ -60,7 +56,6 @@ export function DownloadGroup({ const { lastPacket, - progress, pauseDownload, resumeDownload, cancelDownload, @@ -69,11 +64,26 @@ export function DownloadGroup({ resumeSeeding, } = useDownload(); + const peakSpeedsRef = useRef>({}); + useEffect(() => { + if (lastPacket?.gameId && lastPacket.downloadSpeed) { + const currentPeak = peakSpeedsRef.current[lastPacket.gameId] || 0; + if (lastPacket.downloadSpeed > currentPeak) { + peakSpeedsRef.current[lastPacket.gameId] = lastPacket.downloadSpeed; + } + } + }, [lastPacket?.gameId, lastPacket?.downloadSpeed]); + const isGameSeeding = (game: LibraryGame) => { + const entry = seedingStatus.find((s) => s.gameId === game.id); + if (entry && entry.status) return entry.status === "seeding"; + return game.download?.status === "seeding"; + }; + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; const isGameDownloading = lastPacket?.gameId === 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 +91,100 @@ 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) return ""; - return map; - }, [seedingStatus]); + try { + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + } catch (err) { + return ""; + } + }; + + const getStatusText = (game: LibraryGame) => { + const isGameDownloading = lastPacket?.gameId === game.id; + const status = game.download?.status; + + if (game.download?.extracting) { + return t("extracting"); + } + + if (isGameDeleting(game.id)) { + return t("deleting"); + } + + if (game.download?.progress === 1) { + const isTorrent = game.download?.downloader === Downloader.Torrent; + if (isTorrent) { + if (isGameSeeding(game)) { + return `${t("completed")} (${t("seeding")})`; + } + return `${t("completed")} (${t("paused")})`; + } + return t("completed"); + } + + if (isGameDownloading) { + if (lastPacket.isDownloadingMetadata) { + return t("downloading_metadata"); + } + if (lastPacket.isCheckingFiles) { + return t("checking_files"); + } + if (lastPacket.timeRemaining && lastPacket.timeRemaining > 0) { + return calculateETA(); + } + return t("calculating_eta"); + } + + if (status === "paused") { + return t("paused"); + } + if (status === "waiting") { + return t("calculating_eta"); + } + if (status === "error") { + return t("paused"); + } + + return t("paused"); + }; + + const getSeedsPeersText = (game: LibraryGame) => { + const isGameDownloading = lastPacket?.gameId === game.id; + const isTorrent = game.download?.downloader === Downloader.Torrent; + + if (!isTorrent) return null; + + if (game.download?.progress === 1 && isGameSeeding(game)) { + if ( + isGameDownloading && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) + ) { + return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`; + } + return null; + } + + if ( + isGameDownloading && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) + ) { + return `${lastPacket.numSeeds} seeds, ${lastPacket.numPeers} peers`; + } + + return null; + }; const extractGameDownload = useCallback( async (shop: GameShop, objectId: string) => { @@ -99,102 +194,6 @@ 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; @@ -202,7 +201,7 @@ export function DownloadGroup({ const deleting = isGameDeleting(game.id); if (game.download?.progress === 1) { - return [ + const actions = [ { label: t("install"), disabled: deleting, @@ -224,7 +223,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 +234,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 +249,7 @@ export function DownloadGroup({ }, }, ]; + return actions.filter((action) => action.show !== false); } if (isGameDownloading) { @@ -308,6 +308,17 @@ export function DownloadGroup({