Compare commits

..

3 Commits

45 changed files with 551 additions and 1329 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.6",
"version": "3.7.5",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
@@ -70,7 +70,6 @@
"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",

2
proto

Submodule proto updated: 6f11c99c57...7a23620f93

View File

@@ -16,6 +16,7 @@
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Available",
"my_library": "My library",
"downloading_metadata": "{{title}} (Downloading metadata…)",
"paused": "{{title}} (Paused)",
@@ -115,7 +116,6 @@
"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"
@@ -203,7 +203,6 @@
"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",
@@ -417,10 +416,10 @@
"options": "Manage",
"extract": "Extract 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"
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Downloads path",

View File

@@ -16,6 +16,7 @@
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Disponible",
"my_library": "Mi Librería",
"downloading_metadata": "{{title}} (Descargando metadatos…)",
"paused": "{{title}} (Pausado)",
@@ -414,7 +415,11 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Ruta de descarga",
@@ -458,7 +463,6 @@
"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",
@@ -564,19 +568,6 @@
"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": {

View File

@@ -27,69 +27,7 @@
"friends": "Amis",
"need_help": "Besoin d'aide ?",
"favorites": "Favoris",
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
"library": "Bibliothèque",
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
"custom_game_modal": "Ajouter un jeu personnalisé",
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
"custom_game_modal_executable_path": "Chemin de l'exécutable",
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
"custom_game_modal_title": "Titre",
"custom_game_modal_enter_title": "Entrer le titre",
"custom_game_modal_browse": "Parcourir",
"custom_game_modal_cancel": "Annuler",
"custom_game_modal_add": "Ajouter le jeu",
"custom_game_modal_adding": "Ajout du jeu…",
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
"custom_game_modal_failed": "Échec de lajout du jeu personnalisé",
"custom_game_modal_executable": "Exécutable",
"edit_game_modal": "Personnaliser les ressources",
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
"edit_game_modal_title": "Titre",
"edit_game_modal_enter_title": "Entrer le titre",
"edit_game_modal_image": "Image",
"edit_game_modal_select_image": "Sélectionner une image",
"edit_game_modal_browse": "Parcourir",
"edit_game_modal_image_preview": "Aperçu de limage",
"edit_game_modal_icon": "Icône",
"edit_game_modal_select_icon": "Sélectionner une icône",
"edit_game_modal_icon_preview": "Aperçu de licône",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Sélectionner un logo",
"edit_game_modal_logo_preview": "Aperçu du logo",
"edit_game_modal_hero": "Bannière de la bibliothèque",
"edit_game_modal_select_hero": "Sélectionner limage de bannière",
"edit_game_modal_hero_preview": "Aperçu de la bannière",
"edit_game_modal_cancel": "Annuler",
"edit_game_modal_update": "Mettre à jour",
"edit_game_modal_updating": "Mise à jour…",
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
"edit_game_modal_success": "Ressources mises à jour avec succès",
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
"edit_game_modal_image_filter": "Image",
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
"edit_game_modal_assets": "Ressources",
"edit_game_modal_drop_icon_image_here": "Déposez limage de licône ici",
"edit_game_modal_drop_logo_image_here": "Déposez limage du logo ici",
"edit_game_modal_drop_hero_image_here": "Déposez limage de la bannière ici",
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer licône",
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
"install_decky_plugin": "Installer le plugin Decky",
"update_decky_plugin": "Mettre à jour le plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
"decky_plugin_installation_failed": "Échec de linstallation du plugin Decky : {{error}}",
"decky_plugin_installation_error": "Erreur lors de linstallation du plugin Decky : {{error}}",
"confirm": "Confirmer",
"cancel": "Annuler"
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
},
"header": {
"search": "Rechercher",
@@ -99,15 +37,7 @@
"search_results": "Résultats de la recherche",
"settings": "Paramètres",
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.",
"search_library": "Rechercher dans la bibliothèque",
"recent_searches": "Recherches récentes",
"suggestions": "Suggestions",
"clear_history": "Effacer",
"remove_from_history": "Supprimer de l'historique",
"loading": "Chargement…",
"no_results": "Aucun résultat",
"library": "Bibliothèque"
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
},
"bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours",
@@ -117,8 +47,7 @@
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Installation terminée",
"installation_complete_message": "Redistribuables communs installés avec succès",
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
"installation_complete_message": "Redistribuables communs installés avec succès"
},
"catalogue": {
"search": "Filtrer…",
@@ -269,113 +198,7 @@
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
"game_removed_from_favorites": "Jeu retiré des favoris",
"game_added_to_favorites": "Jeu ajouté aux favoris",
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés",
"already_in_library": "Déjà dans la bibliothèque",
"create_shortcut_simple": "Créer un raccourci",
"properties": "Propriétés",
"extracting": "Extraction en cours",
"new_download_option": "Nouveau",
"create_steam_shortcut": "Créer un raccourci Steam",
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"failed_update_favorites": "Échec de la mise à jour des favoris",
"game_removed_from_library": "Jeu retiré de la bibliothèque",
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
"files_removed_success": "Fichiers supprimés avec succès",
"failed_remove_files": "Échec de la suppression des fichiers",
"rating_count": "Évaluations",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"reviews": "Avis",
"review_played_for": "Temps de jeu",
"leave_a_review": "Laisser un avis",
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
"sort_newest": "Les plus récents",
"sort_oldest": "Les plus anciens",
"sort_highest_score": "Meilleure note",
"sort_lowest_score": "Note la plus basse",
"sort_most_voted": "Les plus votés",
"no_reviews_yet": "Aucun avis pour le moment",
"be_first_to_review": "Soyez le premier à donner votre avis !",
"rating": "Note",
"rating_stats": "Évaluation",
"rating_very_negative": "Très négatif",
"rating_negative": "Négatif",
"rating_neutral": "Neutre",
"rating_positive": "Positif",
"rating_very_positive": "Très positif",
"submit_review": "Envoyer",
"submitting": "Envoi…",
"review_submitted_successfully": "Avis envoyé avec succès !",
"review_submission_failed": "Échec de lenvoi de lavis. Veuillez réessayer.",
"review_cannot_be_empty": "Le champ de lavis ne peut pas être vide.",
"review_deleted_successfully": "Avis supprimé avec succès.",
"review_deletion_failed": "Échec de la suppression de lavis.",
"loading_reviews": "Chargement des avis…",
"loading_more_reviews": "Chargement de plus davis…",
"load_more_reviews": "Charger plus davis",
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
"yes": "Oui",
"maybe_later": "Peut-être plus tard",
"backup_failed": "Échec de la sauvegarde",
"update_playtime_title": "Mettre à jour le temps de jeu",
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
"update_playtime": "Mettre à jour le temps de jeu",
"update_playtime_success": "Temps de jeu mis à jour avec succès",
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
"update_game_playtime": "Mettre à jour le temps de jeu",
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
"game_removed_from_pinned": "Jeu retiré des épinglés",
"game_added_to_pinned": "Jeu ajouté aux épinglés",
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
"artifact_renamed": "Sauvegarde renommée avec succès",
"rename_artifact": "Renommer la sauvegarde",
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
"artifact_name_label": "Nom de la sauvegarde",
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
"save_changes": "Enregistrer les modifications",
"required_field": "Ce champ est requis",
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
"freeze_backup": "Épingler pour éviter lécrasement automatique",
"unfreeze_backup": "Désépingler",
"backup_frozen": "Sauvegarde épinglée",
"backup_unfrozen": "Sauvegarde désépinglée",
"backup_freeze_failed": "Échec de lépinglage de la sauvegarde",
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
"edit_game_modal_button": "Personnaliser les ressources du jeu",
"game_details": "Détails du jeu",
"prices": "Prix",
"no_prices_found": "Aucun prix trouvé",
"view_all_prices": "Cliquer pour voir tous les prix",
"retail_price": "Prix officiel",
"keyshop_price": "Prix Keyshop",
"historical_retail": "Historique officiel",
"historical_keyshop": "Historique Keyshop",
"language": "Langue",
"caption": "Sous-titres",
"audio": "Audio",
"filter_by_source": "Filtrer par source",
"no_repacks_found": "Aucune source trouvée pour ce jeu",
"delete_review": "Supprimer lavis",
"remove_review": "Retirer lavis",
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
"delete_review_modal_description": "Cette action est irréversible.",
"delete_review_modal_delete_button": "Supprimer",
"delete_review_modal_cancel_button": "Annuler",
"vote_failed": "Échec de lenregistrement de votre vote. Veuillez réessayer.",
"show_original": "Afficher loriginal",
"show_translation": "Afficher la traduction",
"show_original_translated_from": "Afficher loriginal (traduit depuis {{language}})",
"hide_original": "Masquer loriginal",
"review_from_blocked_user": "Avis dun utilisateur bloqué",
"show": "Afficher",
"hide": "Masquer"
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
},
"activation": {
"title": "Activer Hydra",
@@ -414,11 +237,7 @@
"resume_seeding": "Reprendre le partage",
"options": "Gérer",
"extract": "Extraire les fichiers",
"extracting": "Extraction des fichiers…",
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
"delete_archive_description": "Le fichier a été extrait avec succès et nest plus nécessaire.",
"yes": "Oui",
"no": "Non"
"extracting": "Extraction des fichiers…"
},
"settings": {
"downloads_path": "Chemin des téléchargements",
@@ -547,40 +366,7 @@
"bottom-left": "En bas à gauche",
"bottom-center": "En bas au centre",
"bottom-right": "En bas à droite",
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu",
"adding": "Ajout…",
"failed_add_download_source": "Échec de lajout de la source de téléchargement. Veuillez réessayer.",
"download_source_already_exists": "Cette URL de source existe déjà",
"download_source_pending_matching": "Mise à jour imminente",
"download_source_matched": "À jour",
"download_source_matching": "Mise à jour",
"download_source_failed": "Erreur",
"download_source_no_information": "Aucune information disponible",
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
"importing": "Importation…",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"enable_steam_achievements": "Activer la recherche de succès Steam",
"alignment": "Alignement",
"variation": "Variation",
"default": "Par défaut",
"rare": "Rare",
"platinum": "Platine",
"hidden": "Caché",
"test_notification": "Notification de test",
"achievement_sound_volume": "Volume du son de succès",
"select_achievement_sound": "Sélectionner un son de succès",
"change_achievement_sound": "Changer le son de succès",
"remove_achievement_sound": "Supprimer le son de succès",
"preview_sound": "Prévisualiser le son",
"select": "Sélectionner",
"preview": "Aperçu",
"remove": "Supprimer",
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
"notification_preview": "Aperçu de la notification de succès",
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement dun jeu"
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
},
"notifications": {
"download_complete": "Téléchargement terminé",

View File

@@ -16,6 +16,7 @@
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)",
@@ -115,7 +116,6 @@
"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"
@@ -191,7 +191,6 @@
"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",
@@ -405,10 +404,10 @@
"options": "Gerenciar",
"extract": "Extrair 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"
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -15,6 +15,7 @@
"catalogue": "Catálogo",
"downloads": "Transferências",
"settings": "Definições",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (A transferir metadados…)",
"paused": "{{title}} (Em pausa)",
@@ -229,7 +230,13 @@
"seeding": "A semear",
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Opções"
"options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Local das transferências",

View File

@@ -16,6 +16,7 @@
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Доступно",
"my_library": "Библиотека",
"downloading_metadata": "{{title}} (Загрузка метаданных…)",
"paused": "{{title}} (Приостановлено)",
@@ -414,7 +415,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",

View File

@@ -1,23 +0,0 @@
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);

View File

@@ -22,7 +22,6 @@ const extractGameDownload = async (
await downloadsSublevel.put(gameKey, {
...download,
extracting: true,
extractionProgress: 0,
});
const gameFilesManager = new GameFilesManager(shop, objectId);

View File

@@ -8,7 +8,6 @@ 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";

View File

@@ -82,7 +82,6 @@ const startGameDownload = async (
queued: true,
extracting: false,
automaticallyExtract,
extractionProgress: 0,
};
try {

View File

@@ -33,7 +33,9 @@ export const loadState = async () => {
await import("./events");
Aria2.spawn();
if (process.platform !== "darwin") {
Aria2.spawn();
}
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken);

View File

@@ -1,5 +1,5 @@
import { app } from "electron";
import Seven, { CommandLineSwitches } from "node-7z";
import cp from "node:child_process";
import path from "node:path";
import { logger } from "./logger";
@@ -9,17 +9,6 @@ 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])
@@ -43,109 +32,43 @@ export class SevenZip {
cwd?: string;
passwords?: string[];
},
onProgress?: (progress: ExtractionProgress) => void
): Promise<ExtractionResult> {
return new Promise((resolve, reject) => {
const tryPassword = (index = -1) => {
const password = passwords[index] ?? "";
logger.info(
`Trying password "${password || "(empty)"}" on ${filePath}`
);
successCb: () => void,
errorCb: () => void
) {
const tryPassword = (index = -1) => {
const password = passwords[index] ?? "";
logger.info(`Trying password ${password} on ${filePath}`);
const extractedFiles: string[] = [];
let fileCount = 0;
const args = ["x", filePath, "-y", "-p" + password];
const options: CommandLineSwitches = {
$bin: this.binaryPath,
$progress: true,
yes: true,
password: password || undefined,
};
if (outputPath) {
args.push("-o" + outputPath);
}
if (outputPath) {
options.outputDir = outputPath;
const child = cp.execFile(this.binaryPath, args, {
cwd,
});
child.once("exit", (code) => {
if (code === 0) {
successCb();
return;
}
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", () => {
if (index < passwords.length - 1) {
logger.info(
`Successfully extracted ${filePath} (${extractedFiles.length} files)`
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
);
resolve({
success: true,
extractedFiles,
});
});
stream.on("error", (err) => {
logger.error(`Extraction error for ${filePath}:`, err);
tryPassword(index + 1);
} else {
logger.info(`Failed to extract file: ${filePath}`);
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<string[]> {
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);
errorCb();
}
});
};
stream.on("end", () => {
resolve(files);
});
stream.on("error", (err) => {
reject(err);
});
});
tryPassword();
}
}

View File

@@ -7,12 +7,9 @@ export class Aria2 {
private static process: cp.ChildProcess | null = null;
public static spawn() {
const binaryPath =
process.platform === "darwin"
? "aria2c"
: app.isPackaged
? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "binaries", "aria2c");
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "binaries", "aria2c");
this.process = cp.spawn(
binaryPath,

View File

@@ -74,16 +74,21 @@ export class DeckyPlugin {
await fs.promises.mkdir(extractPath, { recursive: true });
try {
await SevenZip.extractFile({
filePath: zipPath,
outputPath: extractPath,
});
logger.log(`Plugin extracted to: ${extractPath}`);
return extractPath;
} catch {
throw new Error("Failed to extract plugin");
}
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"));
}
);
});
}
private static needsSudo(): boolean {

View File

@@ -126,10 +126,21 @@ 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);
@@ -143,7 +154,6 @@ export class DownloadManager {
shouldSeed: true,
queued: false,
extracting: shouldExtract,
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
});
} else {
await downloadsSublevel.put(gameId, {
@@ -152,22 +162,12 @@ 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
@@ -209,18 +209,6 @@ 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,
})
);
}
}
}

View File

@@ -3,58 +3,24 @@ 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, ExtractionProgress } from "./7zip";
import { SevenZip } 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 get gameKey() {
return levelKeys.game(this.shop, this.objectId);
}
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;
const gameKey = levelKeys.game(this.shop, this.objectId);
const download = await downloadsSublevel.get(gameKey);
await downloadsSublevel.put(this.gameKey, {
...download,
await downloadsSublevel.put(gameKey, {
...download!,
extracting: false,
extractionProgress: 0,
});
WindowManager.mainWindow?.webContents.send(
@@ -64,10 +30,6 @@ 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);
@@ -80,66 +42,53 @@ export class GameFilesManager {
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
);
if (filesToExtract.length === 0) return;
await this.updateExtractionProgress(0, true);
const totalFiles = filesToExtract.length;
let completedFiles = 0;
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
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();
}
);
}
} catch (err) {
logger.error(`Failed to extract file: ${file}`, err);
await this.clearExtractionState();
return;
});
})
);
compressedFiles.forEach((file) => {
const extractionPath = path.join(directoryPath, file);
if (fs.existsSync(extractionPath)) {
fs.unlink(extractionPath, (err) => {
if (err) {
logger.error(`Failed to delete file: ${file}`, err);
this.clearExtractionState();
}
});
}
}
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(this.gameKey),
gamesSublevel.get(this.gameKey),
downloadsSublevel.get(gameKey),
gamesSublevel.get(gameKey),
]);
if (!download) return;
await downloadsSublevel.put(this.gameKey, {
...download,
await downloadsSublevel.put(gameKey, {
...download!,
extracting: false,
extractionProgress: 0,
});
WindowManager.mainWindow?.webContents.send(
@@ -148,15 +97,17 @@ export class GameFilesManager {
this.objectId
);
if (publishNotification && game) {
publishExtractionCompleteNotification(game);
if (publishNotification) {
publishExtractionCompleteNotification(game!);
}
}
async extractDownloadedFile() {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([
downloadsSublevel.get(this.gameKey),
gamesSublevel.get(this.gameKey),
downloadsSublevel.get(gameKey),
gamesSublevel.get(gameKey),
]);
if (!download || !game) return false;
@@ -168,39 +119,39 @@ export class GameFilesManager {
path.parse(download.folderName!).name
);
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) {
SevenZip.extractFile(
{
filePath,
outputPath: extractionPath,
passwords: ["online-fix.me", "steamrip.com"],
},
async () => {
await this.extractFilesInDirectory(extractionPath);
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
WindowManager.mainWindow?.webContents.send(
"on-archive-deletion-prompt",
[filePath]
);
fs.unlink(filePath, (err) => {
if (err) {
logger.error(
`Failed to delete file: ${download.folderName}`,
err
);
this.clearExtractionState();
}
});
}
await downloadsSublevel.put(this.gameKey, {
...download,
await downloadsSublevel.put(gameKey, {
...download!,
folderName: path.parse(download.folderName!).name,
});
await this.setExtractionComplete();
this.setExtractionComplete();
},
() => {
this.clearExtractionState();
}
} catch (err) {
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
await this.clearExtractionState();
}
);
return true;
}

View File

@@ -36,13 +36,16 @@ 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}`, {
}>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
headers: {
Authorization: `Bearer ${this.token}`,
"X-Website-Token": WT,
},
});

View File

@@ -58,13 +58,7 @@ export class HydraApi {
const decodedBase64 = atob(payload as string);
const jsonData = JSON.parse(decodedBase64);
const {
accessToken,
expiresIn,
refreshToken,
featurebaseJwt,
workwondersJwt,
} = jsonData;
const { accessToken, expiresIn, refreshToken } = jsonData;
const now = new Date();
@@ -91,8 +85,6 @@ export class HydraApi {
accessToken,
refreshToken,
tokenExpirationTimestamp,
featurebaseJwt,
workwondersJwt,
},
{ valueEncoding: "json" }
);

View File

@@ -1,87 +0,0 @@
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<string, unknown>;
_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;
}

View File

@@ -138,8 +138,7 @@ export class WindowManager {
(details, callback) => {
if (
details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("chatwoot") ||
details.url.includes("workwonders")
details.url.includes("chatwoot")
) {
return callback(details);
}
@@ -160,8 +159,7 @@ export class WindowManager {
if (
details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("featurebase") ||
details.url.includes("chatwoot") ||
details.url.includes("workwonders")
details.url.includes("chatwoot")
) {
return callback(details);
}
@@ -356,6 +354,8 @@ export class WindowManager {
public static async createNotificationWindow() {
if (this.notificationWindow) return;
if (process.platform === "darwin") return;
const userPreferences = await db.get<string, UserPreferences | undefined>(
levelKeys.userPreferences,
{

View File

@@ -267,29 +267,6 @@ 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) =>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@@ -19,14 +19,11 @@ 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,
@@ -81,10 +78,6 @@ export function App() {
const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => {
Promise.all([
levelDBService.get("userPreferences", null, "json"),
@@ -191,23 +184,12 @@ 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, dispatch]);
}, [onSignIn, updateLibrary, clearUserDetails]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
@@ -299,12 +281,6 @@ export function App() {
feature={hydraCloudFeature}
/>
<ArchiveDeletionModal
visible={showArchiveDeletionModal}
archivePaths={archivePaths}
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}

View File

@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
useDownload,
useLibrary,
useToast,
@@ -27,8 +26,6 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -71,20 +68,6 @@ 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;
@@ -126,7 +109,6 @@ export function BottomPanel() {
eta,
downloadSpeed,
commonRedistStatus,
extraction,
]);
return (
@@ -140,10 +122,10 @@ export function BottomPanel() {
</button>
<button
data-open-workwonders-changelog-mini
data-featurebase-changelog
className="bottom-panel__version-button"
>
<small>
<small data-featurebase-changelog>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>

View File

@@ -1,61 +0,0 @@
@use "../../scss/globals.scss";
.fullscreen-media-modal {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: relative;
background-color: rgba(0, 0, 0, 0.5);
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 4);
right: calc(globals.$spacing-unit * 3);
cursor: pointer;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border: 1px solid globals.$border-color;
padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
z-index: 10;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
&__image-container {
max-width: 90%;
max-height: 90%;
display: flex;
justify-content: center;
align-items: center;
}
&__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes image-appear {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -1,88 +0,0 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { Backdrop } from "../backdrop/backdrop";
import "./fullscreen-media-modal.scss";
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
src: string | null | undefined;
alt?: string;
}
export function FullscreenMediaModal({
visible,
onClose,
src,
alt,
}: FullscreenMediaModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("modal");
useEffect(() => {
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [onClose, visible]);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (containerRef.current) {
const clickedOnImage = containerRef.current.contains(e.target as Node);
if (!clickedOnImage) {
onClose();
}
}
};
if (visible) {
window.addEventListener("mousedown", onMouseDown);
}
return () => {
window.removeEventListener("mousedown", onMouseDown);
};
}, [onClose, visible]);
if (!visible || !src) return null;
return createPortal(
<Backdrop>
<dialog className="fullscreen-media-modal" open aria-label={alt}>
<button
type="button"
onClick={onClose}
className="fullscreen-media-modal__close-button"
aria-label={t("close")}
>
<XIcon size={24} />
</button>
<div
ref={containerRef}
className="fullscreen-media-modal__image-container"
>
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
</div>
</dialog>
</Backdrop>,
document.body
);
}

View File

@@ -20,4 +20,3 @@ export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating";
export * from "./search-dropdown/search-dropdown";
export * from "./fullscreen-media-modal/fullscreen-media-modal";

View File

@@ -32,4 +32,15 @@ export const routes = [
nameKey: "settings",
render: () => <GearIcon />,
},
{
path: "https://hydrawrapped.com",
nameKey: "hydra_2025_wrapped",
render: () => (
<img
src="https://cdn.losbroxas.org/thumbnail_hydra_badge2_fb01af31e3.png"
alt="Hydra 2025 Wrapped"
style={{ width: 16, height: 16 }}
/>
),
},
];

View File

@@ -88,6 +88,34 @@
);
}
}
&--wrapped {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.25) 0%,
rgba(123, 104, 238, 0.2) 25%,
rgba(59, 130, 246, 0.25) 50%,
rgba(96, 165, 250, 0.2) 75%,
rgba(74, 144, 226, 0.25) 100%
);
background-size: 200% 200%;
animation: wrapped-gradient-flow 8s ease infinite;
color: globals.$muted-color;
position: relative;
overflow: hidden;
&:hover {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.35) 0%,
rgba(123, 104, 238, 0.3) 25%,
rgba(59, 130, 246, 0.35) 50%,
rgba(96, 165, 250, 0.3) 75%,
rgba(74, 144, 226, 0.35) 100%
);
background-size: 200% 200%;
}
}
}
&__menu-item-button {
@@ -106,6 +134,21 @@
overflow: hidden;
}
&__menu-item-marquee {
overflow: hidden;
flex: 1;
min-width: 0;
}
&__menu-item-marquee-content {
display: inline-flex;
}
&__menu-item-marquee-content span {
display: inline-block;
flex-shrink: 0;
}
&__game-icon {
width: 20px;
height: 20px;
@@ -228,3 +271,24 @@
}
}
}
@keyframes wrapped-gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 1em));
}
}

View File

@@ -22,6 +22,8 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { logger } from "@renderer/logger";
import { motion } from "framer-motion";
import {
CommentDiscussionIcon,
PlayIcon,
@@ -238,8 +240,32 @@ export function Sidebar() {
return game.title;
};
const handleSidebarItemClick = (path: string) => {
if (path !== location.pathname) {
const handleSidebarItemClick = async (path: string) => {
if (path.startsWith("http")) {
if (path === "https://hydrawrapped.com") {
try {
const auth = await window.electron.getAuth();
if (auth) {
const payload = {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
expiresIn: 3600,
};
const base64Payload = btoa(JSON.stringify(payload));
window.electron.openExternal(
`${path}?payload=${encodeURIComponent(base64Payload)}`
);
} else {
window.electron.openExternal(path);
}
} catch (error) {
logger.error("Failed to get auth for wrapped:", error);
window.electron.openExternal(path);
}
} else {
window.electron.openExternal(path);
}
} else if (path !== location.pathname) {
navigate(path);
}
};
@@ -297,7 +323,10 @@ export function Sidebar() {
<li
key={nameKey}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active": location.pathname === path,
"sidebar__menu-item--active":
!path.startsWith("http") && location.pathname === path,
"sidebar__menu-item--wrapped":
nameKey === "hydra_2025_wrapped",
})}
>
<button
@@ -306,7 +335,33 @@ export function Sidebar() {
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
{nameKey === "hydra_2025_wrapped" ? (
<div className="sidebar__menu-item-marquee">
<motion.div
className="sidebar__menu-item-marquee-content"
animate={{
x: ["0%", "-50%"],
}}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 8,
ease: "linear",
},
}}
>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
</motion.div>
</div>
) : (
<span>{t(nameKey)}</span>
)}
</button>
</li>
))}

View File

@@ -208,13 +208,6 @@ 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<boolean>;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;

View File

@@ -1,24 +1,17 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { DownloadProgress, GameShop } from "@types";
export interface ExtractionInfo {
visibleId: string;
progress: number;
}
import type { DownloadProgress } from "@types";
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({
@@ -45,23 +38,6 @@ 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;
},
},
});
@@ -70,6 +46,4 @@ export const {
clearDownload,
setGameDeleting,
removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
} = downloadSlice.actions;

View File

@@ -1,44 +0,0 @@
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<ArchiveDeletionModalProps>) {
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 (
<ConfirmationModal
visible={visible}
title={t("delete_archive_title", { fileName })}
descriptionText={t("delete_archive_description")}
confirmButtonLabel={t("yes")}
cancelButtonLabel={t("no")}
onConfirm={handleConfirm}
onClose={onClose}
/>
);
}

View File

@@ -108,11 +108,16 @@
cursor: pointer;
display: flex;
align-items: center;
transition: scale 0.2s ease;
transition: opacity 0.2s ease;
outline: none;
&:hover {
scale: 1.05;
opacity: 0.8;
}
&:focus,
&:focus-visible {
outline: none;
}
}
@@ -390,21 +395,6 @@
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%;
@@ -421,21 +411,6 @@
gap: calc(globals.$spacing-unit / 1);
}
&__simple-title-button {
background: none;
border: none;
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;
@@ -536,9 +511,5 @@
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
&--extraction {
background-color: #fff;
}
}
}

View File

@@ -128,20 +128,16 @@ function SpeedChart({
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;
const hex = color.replace("#", "");
r = Number.parseInt(hex.substring(0, 2), 16);
g = Number.parseInt(hex.substring(2, 4), 16);
b = Number.parseInt(hex.substring(4, 6), 16);
} 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;
r = Number.parseInt(matches[0]);
g = Number.parseInt(matches[1]);
b = Number.parseInt(matches[2]);
}
}
const displaySpeeds = speeds.slice(-totalBars);
@@ -207,7 +203,6 @@ function SpeedChart({
interface HeroDownloadViewProps {
game: LibraryGame;
isGameDownloading: boolean;
isGameExtracting?: boolean;
downloadSpeed: number;
finalDownloadSize: string;
peakSpeed: number;
@@ -226,7 +221,6 @@ interface HeroDownloadViewProps {
function HeroDownloadView({
game,
isGameDownloading,
isGameExtracting = false,
downloadSpeed,
finalDownloadSize,
peakSpeed,
@@ -284,17 +278,11 @@ function HeroDownloadView({
<div className="download-group__progress-row download-group__progress-row--bar">
<div className="download-group__progress-wrapper">
<div className="download-group__progress-info-row">
{isGameExtracting && (
<span className="download-group__progress-status">
{t("extracting")}
</span>
)}
{!isGameExtracting && lastPacket?.isCheckingFiles && (
{lastPacket?.isCheckingFiles ? (
<span className="download-group__progress-status">
{t("checking_files")}
</span>
)}
{!isGameExtracting && !lastPacket?.isCheckingFiles && (
) : (
<span className="download-group__progress-size">
<DownloadIcon size={14} />
{isGameDownloading && lastPacket
@@ -305,7 +293,7 @@ function HeroDownloadView({
<span></span>
</div>
<div className="download-group__progress-info-row">
{!lastPacket?.isCheckingFiles && !isGameExtracting && (
{!lastPacket?.isCheckingFiles && (
<span className="download-group__progress-time">
{isGameDownloading &&
lastPacket?.timeRemaining &&
@@ -317,50 +305,50 @@ function HeroDownloadView({
)}
</span>
)}
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
{(!lastPacket?.isCheckingFiles || currentProgress > 0) && (
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
)}
</div>
<div className="download-group__progress-bar">
<div
className={`download-group__progress-fill ${isGameExtracting ? "download-group__progress-fill--extraction" : ""}`}
className="download-group__progress-fill"
style={{
width: `${currentProgress * 100}%`,
}}
/>
</div>
</div>
{!isGameExtracting && (
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
<ColumnsIcon size={14} />
{t("pause")}
</button>
</div>
)}
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
</button>
</div>
</div>
</div>
@@ -372,7 +360,7 @@ function HeroDownloadView({
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">
{t("network")}:
{t("network")}
</span>
<span className="download-group__stat-value">
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
@@ -385,37 +373,38 @@ function HeroDownloadView({
<GraphIcon size={16} />
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">{t("peak")}:</span>
<span className="download-group__stat-label">{t("peak")}</span>
<span className="download-group__stat-value">
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
</span>
</div>
</div>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<span className="download-group__stat-label">
Seeds:{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, Peers:{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
</div>
</div>
)}
{game.download?.downloader && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<div
className="download-group__stat-content"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<span className="download-group__stat-label">
{t("seeds")}{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, {t("peers")}{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
)}
</div>
</div>
)}
@@ -456,8 +445,6 @@ export function DownloadGroup({
(state) => state.userPreferences.value
);
const extraction = useAppSelector((state) => state.download.extraction);
const { updateLibrary } = useLibrary();
const {
@@ -835,21 +822,16 @@ export function DownloadGroup({
if (isDownloadingGroup && library.length > 0) {
const game = library[0];
const isGameExtracting = extraction?.visibleId === game.id;
const isGameDownloading =
isGameDownloadingMap[game.id] && !isGameExtracting;
const isGameDownloading = isGameDownloadingMap[game.id];
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 currentProgress =
isGameDownloading && lastPacket
? lastPacket.progress
: game.download?.progress || 0;
const dominantColor = dominantColors[game.id] || "#fff";
@@ -857,7 +839,6 @@ export function DownloadGroup({
<HeroDownloadView
game={game}
isGameDownloading={isGameDownloading}
isGameExtracting={isGameExtracting}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
@@ -892,8 +873,14 @@ export function DownloadGroup({
<li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
@@ -901,20 +888,27 @@ export function DownloadGroup({
<div className="download-group__simple-info">
<button
type="button"
className="download-group__simple-title"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button"
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
textAlign: "left",
width: "100%",
}}
>
<h3 className="download-group__simple-title">{game.title}</h3>
{game.title}
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
</div>
<div className="download-group__simple-meta-row">
{extraction?.visibleId === game.id ? (
{game.download?.extracting ? (
<span className="download-group__simple-extracting">
{t("extracting")} (
{Math.round(extraction.progress * 100)}%)
{t("extracting")}
</span>
) : (
<span className="download-group__simple-size">

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
@@ -13,7 +13,6 @@ 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");
@@ -40,13 +39,11 @@ export default function Downloads() {
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
const unsubscribeExtraction = window.electron.onExtractionComplete(() => {
const unsubscribe = window.electron.onExtractionComplete(() => {
updateLibrary();
});
return () => {
unsubscribeExtraction();
};
return () => unsubscribe();
}, [updateLibrary]);
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
@@ -75,10 +72,8 @@ export default function Downloads() {
/* Game has been manually added to the library */
if (!next.download) return prev;
/* Is downloading or extracting */
const isExtracting =
next.download.extracting || extraction?.visibleId === next.id;
if (lastPacket?.gameId === next.id || isExtracting)
/* Is downloading */
if (lastPacket?.gameId === next.id || next.download.extracting)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
@@ -101,7 +96,7 @@ export default function Downloads() {
queued,
complete,
};
}, [library, lastPacket?.gameId, extraction?.visibleId]);
}, [library, lastPacket?.gameId]);
const downloadGroups = [
{

View File

@@ -1,12 +1,7 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDownloadProgress } from "@renderer/helpers";
import {
useAppSelector,
useDate,
useDownload,
useFormat,
} from "@renderer/hooks";
import { 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";
@@ -22,9 +17,6 @@ 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) {
@@ -60,16 +52,6 @@ export function HeroPanelPlaytime() {
const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id;
const extractionInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
{t("extracting")}
</Link>
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
</div>
);
const downloadInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
@@ -90,8 +72,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("not_played_yet", { title: game?.title })}</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{hasDownload && downloadInProgressInfo}
</>
);
}
@@ -100,8 +81,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("playing_now")}</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{hasDownload && downloadInProgressInfo}
</>
);
}
@@ -133,9 +113,9 @@ export function HeroPanelPlaytime() {
})}
</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{!isExtracting && !hasDownload && (
{hasDownload ? (
downloadInProgressInfo
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,

View File

@@ -80,11 +80,5 @@
&--disabled {
opacity: globals.$disabled-opacity;
}
&--extraction {
&::-webkit-progress-value {
background-color: #fff;
}
}
}
}

View File

@@ -1,7 +1,7 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector, useDate, useDownload } from "@renderer/hooks";
import { useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
@@ -18,13 +18,9 @@ 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;
@@ -53,8 +49,6 @@ export function HeroPanel() {
(game?.download?.status === "active" && game?.download?.progress < 1) ||
game?.download?.status === "paused";
const showExtractionProgressBar = isExtracting;
return (
<div className="hero-panel__container">
<div className="hero-panel">
@@ -78,14 +72,6 @@ export function HeroPanel() {
}`}
/>
)}
{showExtractionProgressBar && (
<progress
max={1}
value={extraction?.progress ?? 0}
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
/>
)}
</div>
</div>
);

View File

@@ -87,12 +87,16 @@ export function LibraryTab({
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<li
key={game.objectId}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</li>
@@ -134,6 +138,9 @@ export function LibraryTab({
<motion.li
key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }}
className="user-library-game__wrapper"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
initial={
isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 }
@@ -160,8 +167,6 @@ export function LibraryTab({
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</motion.li>

View File

@@ -25,16 +25,12 @@ import "./user-library-game-card.scss";
interface UserLibraryGameCardProps {
game: UserGame;
statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
sortBy?: string;
}
export function UserLibraryGameCard({
game,
statIndex,
onMouseEnter,
onMouseLeave,
sortBy,
}: UserLibraryGameCardProps) {
const { userProfile, isMe, getUserLibraryGames } =
@@ -130,129 +126,119 @@ export function UserLibraryGameCard({
return (
<>
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="user-library-game__wrapper"
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
title={isTooltipHovered ? undefined : game.title}
>
<button
type="button"
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{isMe && (
<div className="user-library-game__actions-container">
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
<div className="user-library-game__overlay">
{isMe && (
<div className="user-library-game__actions-container">
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="user-library-game__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
</div>
{userProfile?.hasActiveSubscription &&
game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<TrophyIcon size={13} />
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
{game.unlockedAchievementCount} / {game.achievementCount}
</span>
</div>
<progress
max={1}
value={
game.unlockedAchievementCount / game.achievementCount
}
className="user-library-game__achievements-progress"
/>
{game.achievementsPointsEarnedSum > 0 && (
<div
className="user-library-game__stats-item"
style={{
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
<HydraIcon width={16} height={16} />
{formatAchievementPoints(
game.achievementsPointsEarnedSum
)}
</div>
)}
</div>
)}
</div>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
<span>
{formatDownloadProgress(
game.unlockedAchievementCount / game.achievementCount,
1
)}
</span>
</div>
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className="user-library-game__achievements-progress"
/>
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
</li>
</div>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
<Tooltip
id={game.objectId}
style={{

View File

@@ -9,12 +9,7 @@ import {
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import {
Avatar,
Button,
FullscreenMediaModal,
Link,
} from "@renderer/components";
import { Avatar, Button, Link } from "@renderer/components";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
@@ -38,7 +33,6 @@ type FriendAction =
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const {
@@ -252,12 +246,10 @@ export function ProfileHero() {
]);
const handleAvatarClick = useCallback(() => {
if (userProfile?.profileImageUrl) {
setShowFullscreenAvatar(true);
} else if (isMe) {
if (isMe) {
setShowEditProfileModal(true);
}
}, [isMe, userProfile?.profileImageUrl]);
}, [isMe]);
const currentGame = useMemo(() => {
if (isMe) {
@@ -280,13 +272,6 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)}
/>
<FullscreenMediaModal
visible={showFullscreenAvatar}
onClose={() => setShowFullscreenAvatar(false)}
src={userProfile?.profileImageUrl}
alt={userProfile?.displayName}
/>
<section
className="profile-hero__content-box"
style={{ background: !backgroundImage ? heroBackground : undefined }}

View File

@@ -20,8 +20,6 @@ export interface Auth {
accessToken: string;
refreshToken: string;
tokenExpirationTimestamp: number;
featurebaseJwt: string;
workwondersJwt: string;
}
export interface User {
@@ -82,7 +80,6 @@ export interface Download {
timestamp: number;
extracting: boolean;
automaticallyExtract: boolean;
extractionProgress: number;
}
export interface GameAchievement {

View File

@@ -6330,7 +6330,7 @@ jsonwebtoken@^9.0.2:
object.assign "^4.1.4"
object.values "^1.1.6"
jwa@^1.4.2:
jwa@^1.4.1:
version "1.4.2"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9"
integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==
@@ -6340,11 +6340,11 @@ jwa@^1.4.2:
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1"
integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.2"
jwa "^1.4.1"
safe-buffer "^5.0.1"
keyv@^4.0.0, keyv@^4.5.3:
@@ -6438,26 +6438,11 @@ 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"
@@ -6468,11 +6453,6 @@ 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"
@@ -6513,11 +6493,6 @@ 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"
@@ -6897,19 +6872,6 @@ 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"
@@ -6965,11 +6927,6 @@ 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"