commit 7c15c04808a1f953806c4958bfaeee6a5bf5f398 Author: skymike03 Date: Wed Jul 23 23:54:11 2025 +0200 v1.9.7.0 - Ajout support multilangues (beta) , correction de bugs de logique, amĂ©lioration des erreurs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2abdde --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +logs/ +images/ +games/ +__pycache__/ +sources.json +gamelist.xml +*.log +*.rar +*.zip \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..db54960 --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# 🎼 Retro Game Sets Xtra (RGSX) + +RGSX est une application Python basĂ©e sur Pygame. + +--- + +## ✹ FonctionnalitĂ©s + +- **TĂ©lĂ©chargement de jeux** : Prise en charge des fichiers ZIP et gestion des extensions non supportĂ©es grĂące au fichier `info.txt` dans chaque dossier. + - Les tĂ©lĂ©chargements ne nĂ©cessitent aucune authentification ni compte pour la plupart. + - Les systĂšmes notĂ©s `(1fichier)` dans le nom ne seront accessibles que si vous renseignez votre clĂ© API 1fichier (voir plus bas). +- **Historique des tĂ©lĂ©chargements** : Consultez et retĂ©lĂ©chargez les anciens fichiers. +- **Personnalisation des contrĂŽles** : Remappez les touches du clavier ou de la manette Ă  votre convenance. +- **Mode recherche** : Filtrez les jeux par nom pour une navigation rapide. +- **Gestion des erreurs** +- **Interface rĂ©active** : L'interface s'adapte Ă  toutes rĂ©solutions de 800x600 Ă  4K (non testĂ© au-delĂ  de 1920x1080). +- **Mise Ă  jour automatique** (bug d'affichage Ă  amĂ©liorer lors d'une mise Ă  jour) : l'application doit ĂȘtre relancĂ©e aprĂšs sa fermeture automatique. + +--- + +## đŸ–„ïž PrĂ©requis + +### SystĂšme d'exploitation +- Batocera ou Knulli + +### MatĂ©riel +- Manette (optionnelle, mais recommandĂ©e pour une expĂ©rience optimale) ou Clavier. + +### Espace disque +- Espace suffisant dans `/userdata/roms/ports/RGSX` pour stocker les ROMs, images et fichiers de configuration. + +--- + +## 🚀 Installation + +### MĂ©thode 1 : Ligne de commande + +- Sur batocera PC acceder Ă  F1>Applications>xTERM ou +- Depuis un autre pc sur le rĂ©seau avec application Putty, powershell SSH ou autre + +Entrez la commande : +## `curl -L bit.ly/rgsx-install | sh` + +Patientez et regardez le retour Ă  l'Ă©cran ou sur la commande (Ă  amĂ©liorer). +Mettez Ă  jour la liste des jeux via : `Menu > ParamĂštres de jeux > Mettre Ă  jour la liste des jeux `. +Vous trouverez RGSX dans le systĂšme "PORTS" ou "Jeux Amateurs et portages" et dans `/userdata/roms/ports/RGSX` + +--- + +### MĂ©thode 2 : Copie manuelle + +- TĂ©lĂ©chargez le contenu du dĂ©pĂŽt en zip : https://github.com/RetroGameSets/RGSX/archive/refs/heads/main.zip +- Extrayez le tout dans `/userdata/roms/ports/RGSX` (le dossier RGSX devra ĂȘtre créé manuellement). Attention de bien respecter la structure indiquĂ©e plus bas. +- Mettez Ă  jour la liste des jeux via le menu : + `ParamĂštres de jeux > Mettre Ă  jour la liste`. + + +## 🏁 1er dĂ©marrage +--- +> ## IMPORTANT +> Si vous avez une clĂ© API 1Fichier, vous devez la renseigner dans +> `/userdata/saves/ports/RGSX/1FichierAPI.txt` +> si vous souhaitez tĂ©lĂ©charger depuis des liens 1Fichier. +--- + +- Lancez RGSX depuis ports. +- Configurez les contrĂŽles. Ils pourront ĂȘtre reconfigurĂ©s via le menu pause par la suite si erreur. +- Supprimez le fichier `/userdata/saves/ports/rgsx/controls.json` en cas de problĂšme puis relancez l'application. +- L'application tĂ©lĂ©chargera toutes les donnĂ©es nĂ©cessaires automatiquement ensuite. + +--- + +## đŸ•č Utilisation + +### Navigation dans les menus + +- Utilisez les touches directionnelles (D-Pad, flĂšches du clavier) pour naviguer entre les plateformes, jeux et options. +- Appuyez sur la touche configurĂ©e comme start (par dĂ©faut, **P** ou bouton Start sur la manette) pour ouvrir le menu pause. +- Depuis le menu pause, accĂ©dez Ă  l'historique, Ă  l'aide des contrĂŽles (l'affichage des contrĂŽles change suivant le menu oĂč vous ĂȘtes) ou Ă  la reconfiguration des touches. +- Vous pouvez aussi, depuis le menu, rĂ©gĂ©nĂ©rer la liste des systĂšmes/jeux/images pour ĂȘtre sĂ»r d'avoir les derniĂšres mises Ă  jour. + +--- + +### TĂ©lĂ©chargement + +- SĂ©lectionnez une plateforme, puis un jeu. +- Appuyez sur la touche configurĂ©e confirm (par dĂ©faut, **EntrĂ©e** ou bouton **A**) pour lancer le tĂ©lĂ©chargement. +- Suivez la progression dans le menu `download_progress`. + +--- + +### Personnalisation des contrĂŽles + +- Dans le menu pause, sĂ©lectionnez **Remap controls**. +- Suivez les instructions Ă  l'Ă©cran pour mapper chaque action en maintenant la touche ou le bouton pendant 3 secondes. +- Appuyez sur **Échap** pour ignorer une action sans la mapper. + +--- + +### Historique + +- AccĂ©dez Ă  l'historique des tĂ©lĂ©chargements via le menu pause ou en appuyant sur la touche history (par dĂ©faut, **H**). +- SĂ©lectionnez un jeu pour le retĂ©lĂ©charger si nĂ©cessaire. + +--- + +### Logs + +Les logs sont enregistrĂ©s dans `/userdata/roms/ports/RGSX/logs/RGSX.log` pour diagnostiquer les problĂšmes. + +--- + +## 📁 Structure du projet +``` +/userdata/roms/ports/ +RGSX-INSTALL.log # LOG d'installation uniquement +RGSX/ +│ +├── main.py # Point d'entrĂ©e principal de l'application. +├── controls.py # Gestion des Ă©vĂ©nements clavier/manette/souris et navigation dans les menus. +├── controls_mapper.py # Configuration des contrĂŽles. +├── display.py # Rendu des interfaces graphiques avec Pygame. +├── config.py # Configuration globale (chemins, paramĂštres, etc.). +├── network.py # Gestion des tĂ©lĂ©chargements de jeux. +├── history.py # Gestion de l'historique des tĂ©lĂ©chargements. +├── utils.py # Fonctions utilitaires (wrap du texte, troncage etc.). +└── logs/ + └── RGSX.log # Fichier de logs. + +/userdata/saves/ports/ +RGSX/ +│ +├── controls.json # Fichier de mappage des contrĂŽles (gĂ©nĂ©rĂ© aprĂšs le 1er demarrage) +├── history.json # Base de donnĂ©es de l'historique de tĂ©lĂ©chargements (gĂ©nĂ©rĂ© aprĂšs le 1er tĂ©lĂ©chargement) +└── 1FichierAPI.txt # ClĂ© API 1fichier (compte premium et + uniquement) (vide par defaut) +``` + + + + + +--- + +## đŸ€ Contribution + +### Signaler un bug + +1. Consultez les logs dans `/userdata/roms/ports/RGSX/logs/RGSX.log`. +2. Ouvrez une issue sur GitHub avec une description dĂ©taillĂ©e et les logs pertinents. + +### Proposer une fonctionnalitĂ© + +- Soumettez une issue avec une description claire de la fonctionnalitĂ© proposĂ©e. +- Expliquez comment elle s'intĂšgre dans l'application. + +### Contribuer au code + +1. Forkez le dĂ©pĂŽt et crĂ©ez une branche pour votre fonctionnalitĂ© ou correction : +git checkout -b feature/nom-de-votre-fonctionnalitĂ© +2. Testez vos modifications sur Batocera. +3. Soumettez une pull request avec une description dĂ©taillĂ©e. + +--- + +## ⚠ ProblĂšmes connus / À implĂ©menter + +- Gestion des tĂ©lĂ©chargements multiples + +--- + +## 📝 Licence + +Ce projet est libre. Vous ĂȘtes libre de l'utiliser, le modifier et le distribuer selon les termes de cette licence. + +DĂ©veloppĂ© avec ❀ pour les amateurs de jeux rĂ©tro. diff --git a/RGSX.sh b/RGSX.sh new file mode 100644 index 0000000..58fc295 --- /dev/null +++ b/RGSX.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Supprimer SDL_VIDEODRIVER=fbcon pour laisser SDL choisir le pilote +# export SDL_VIDEODRIVER=fbcon +/usr/bin/python3 /userdata/roms/ports/RGSX \ No newline at end of file diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..4d2c045 --- /dev/null +++ b/__main__.py @@ -0,0 +1,779 @@ +import os +os.environ["SDL_FBDEV"] = "/dev/fb0" +import pygame # type: ignore +import asyncio +import platform +import logging +import requests +import queue +import datetime +from display import init_display, draw_loading_screen, draw_error_screen, draw_platform_grid, draw_progress_screen, draw_controls, draw_virtual_keyboard, draw_popup_result_download, draw_extension_warning, draw_pause_menu, draw_controls_help, draw_game_list, draw_history_list, draw_clear_history_dialog, draw_confirm_dialog, draw_redownload_game_cache_dialog, draw_popup, draw_gradient, draw_language_menu, THEME_COLORS +from language import update_valid_states, handle_language_menu_events, _ +from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates +from controls import handle_controls, validate_menu_state, process_key_repeats +from controls_mapper import load_controls_config, map_controls, draw_controls_mapping, ACTIONS +from utils import detect_non_pc, load_sources, check_extension_before_download, extract_zip_data, play_random_music +from history import load_history, save_history +import config +from config import OTA_data_ZIP + +# Configuration du logging +log_dir = os.path.join(config.APP_FOLDER, "logs") +log_file = os.path.join(log_dir, "RGSX.log") +try: + os.makedirs(log_dir, exist_ok=True) + logging.basicConfig( + filename=log_file, + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' + ) +except Exception as e: + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + logging.error(f"Échec de la configuration du logging dans {log_file}: {str(e)}") + +logger = logging.getLogger(__name__) + +# Initialisation de Pygame et des polices +pygame.init() +config.init_font() +pygame.joystick.init() +pygame.mouse.set_visible(True) + +# Initialisation du sĂ©lecteur de langue +update_valid_states() + +# Chargement et initialisation de la langue +from language import initialize_language +initialize_language() +logger.debug(f"Langue initialisĂ©e: {config.current_language}") + +# DĂ©tection du systĂšme non-PC +config.is_non_pc = detect_non_pc() + +# Initialisation de l’écran +screen = init_display() +pygame.display.set_caption("RGSX") +clock = pygame.time.Clock() + +# Initialisation des polices +try: + font_path = os.path.join(config.APP_FOLDER, "assets", "Pixel-UniCode.ttf") + config.font = pygame.font.Font(font_path, 36) # Police principale + config.title_font = pygame.font.Font(font_path, 48) # Police pour les titres + config.search_font = pygame.font.Font(font_path, 48) # Police pour la recherche + config.progress_font = pygame.font.Font(font_path, 36) # Police pour l'affichage de la progression + config.small_font = pygame.font.Font(font_path, 28) # Police pour les petits textes + logger.debug("Police Pixel-UniCode chargĂ©e") +except: + config.font = pygame.font.SysFont("arial", 48) # Police fallback + config.title_font = pygame.font.SysFont("arial", 60) # Police fallback pour les titres + config.search_font = pygame.font.SysFont("arial", 60) # Police fallback pour la recherche + config.progress_font = pygame.font.SysFont("arial", 36) # Police fallback pour l'affichage de la progression + config.small_font = pygame.font.SysFont("arial", 28) # Police fallback pour les petits textes + logger.debug("Police Arial chargĂ©e") + +# Mise Ă  jour de la rĂ©solution dans config +config.screen_width, config.screen_height = pygame.display.get_surface().get_size() +logger.debug(f"RĂ©solution rĂ©elle : {config.screen_width}x{config.screen_height}") + +# Initialisation des variables de grille +config.current_page = 0 +config.selected_platform = 0 +config.selected_key = (0, 0) +config.transition_state = "none" + +# Initialisation des variables de rĂ©pĂ©tition +config.repeat_action = None +config.repeat_key = None +config.repeat_start_time = 0 +config.repeat_last_action = 0 + +# Initialisation des variables pour la popup de musique +current_music_name = None +music_popup_start_time = 0 +# Dossier musique Batocera +music_folder = os.path.join(config.APP_FOLDER, "assets", "music") +music_files = [f for f in os.listdir(music_folder) if f.lower().endswith(('.ogg', '.mp3'))] +current_music = None # Variable pour suivre la musique en cours +if music_files: + current_music = play_random_music(music_files, music_folder, current_music) +else: + logger.debug("Aucune musique trouvĂ©e dans config.APP_FOLDER/assets/music") + + +# Chargement de l'historique +config.history = load_history() +logger.debug(f"Historique chargĂ©: {len(config.history)} entrĂ©es") + +# VĂ©rifier si le fichier de configuration des contrĂŽles existe +controls_file_exists = os.path.exists(config.CONTROLS_CONFIG_PATH) +logger.debug(f"Fichier controls.json existe: {controls_file_exists} Ă  {config.CONTROLS_CONFIG_PATH}") + +# VĂ©rification et chargement de la configuration des contrĂŽles +config.controls_config = load_controls_config() + +# DĂ©terminer l'Ă©tat initial de l'application +if not controls_file_exists: + # Si pas de fichier de contrĂŽles, on commence par les configurer + config.menu_state = "controls_mapping" + config.needs_redraw = True # Forcer le redraw immĂ©diatement + logger.info(f"Pas de fichier de contrĂŽles Ă  {config.CONTROLS_CONFIG_PATH}, configuration des contrĂŽles") + logger.debug("Menu initial: mappage des contrĂŽles") +else: + # Sinon, chargement normal + config.menu_state = "loading" + logger.debug("Menu chargement normal") + +# Initialisation du gamepad +joystick = None +if pygame.joystick.get_count() > 0: + joystick = pygame.joystick.Joystick(0) + joystick.init() + logger.debug("Gamepad initialisĂ©") + +# Initialisation du mixer Pygame +pygame.mixer.pre_init(44100, -16, 2, 4096) +pygame.mixer.init() + + +# Boucle principale +async def main(): + # amazonq-ignore-next-line + global current_music, music_files, music_folder + logger.debug("DĂ©but main") + running = True + loading_step = "none" + sources = [] + config.last_state_change_time = 0 + config.debounce_delay = 50 + config.update_triggered = False + last_redraw_time = pygame.time.get_ticks() + config.last_frame_time = pygame.time.get_ticks() # Initialisation pour Ă©viter erreur + + screen = init_display() + clock = pygame.time.Clock() + + while running: + clock.tick(30) # Limite Ă  60 FPS + if config.update_triggered: + logger.debug("Mise Ă  jour dĂ©clenchĂ©e, arrĂȘt de la boucle principale") + break + + current_time = pygame.time.get_ticks() + + # Forcer redraw toutes les 100 ms dans download_progress + if config.menu_state == "download_progress" and current_time - last_redraw_time >= 100: + config.needs_redraw = True + last_redraw_time = current_time + # Forcer redraw toutes les 100 ms dans history avec tĂ©lĂ©chargement actif + if config.menu_state == "history" and any(entry["status"] == "TĂ©lĂ©chargement" for entry in config.history): + if current_time - last_redraw_time >= 100: + config.needs_redraw = True + last_redraw_time = current_time + # logger.debug("Forcing redraw in history state due to active download") + + # Gestion de la fin du popup + if config.menu_state == "restart_popup" and config.popup_timer > 0: + config.popup_timer -= (current_time - config.last_frame_time) + config.needs_redraw = True + if config.popup_timer <= 0: + config.menu_state = validate_menu_state(config.previous_menu_state) + config.popup_message = "" + config.popup_timer = 0 + config.needs_redraw = True + logger.debug(f"Fermeture automatique du popup, retour Ă  {config.menu_state}") + + # Gestion de la fin du popup update_result + if config.menu_state == "update_result" and current_time - config.update_result_start_time > 5000: + config.menu_state = "platform" # Retour Ă  l'Ă©cran des plateformes + config.update_result_message = "" + config.update_result_error = False + config.needs_redraw = True + logger.debug("Fin popup update_result, retour Ă  platform") + + # Gestion de la rĂ©pĂ©tition automatique des actions + process_key_repeats(sources, joystick, screen) + + # Gestion des Ă©vĂ©nements + events = pygame.event.get() + for event in events: + # Gestion directe des Ă©vĂ©nements pour le menu de langue + if config.menu_state == "language_select" and event.type == pygame.KEYDOWN: + handle_language_menu_events(event, screen) + continue + + if event.type == pygame.USEREVENT + 1: # ÉvĂ©nement de fin de musique + logger.debug("Fin de la musique dĂ©tectĂ©e, lecture d'une nouvelle musique alĂ©atoire") + current_music = play_random_music(music_files, music_folder, current_music) + continue + + if event.type == pygame.QUIT: + config.menu_state = "confirm_exit" + config.confirm_selection = 0 + config.needs_redraw = True + logger.debug("ÉvĂ©nement QUIT dĂ©tectĂ©, passage Ă  confirm_exit") + continue + + start_config = config.controls_config.get("start", {}) + if start_config and ( + (event.type == pygame.KEYDOWN and start_config.get("type") == "key" and event.key == start_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and start_config.get("type") == "button" and event.button == start_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and start_config.get("type") == "axis" and event.axis == start_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == start_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and start_config.get("type") == "hat" and event.value == tuple(start_config.get("value"))) or + (event.type == pygame.MOUSEBUTTONDOWN and start_config.get("type") == "mouse" and event.button == start_config.get("value")) + ): + if config.menu_state not in ["pause_menu", "controls_help", "controls_mapping", "history", "confirm_clear_history"]: + config.previous_menu_state = config.menu_state + config.menu_state = "pause_menu" + config.selected_option = 0 + config.needs_redraw = True + logger.debug(f"Ouverture menu pause depuis {config.previous_menu_state}") + continue + + if config.menu_state == "pause_menu": + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + logger.debug(f"ÉvĂ©nement transmis Ă  handle_controls dans pause_menu: {event.type}") + continue + + if config.menu_state == "controls_help": + cancel_config = config.controls_config.get("cancel", {}) + if ( + (event.type == pygame.KEYDOWN and cancel_config and event.key == cancel_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and cancel_config and cancel_config.get("type") == "button" and event.button == cancel_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and cancel_config and cancel_config.get("type") == "axis" and event.axis == cancel_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == cancel_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and cancel_config and cancel_config.get("type") == "hat" and event.value == tuple(cancel_config.get("value"))) + ): + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug("Controls_help: Annulation, retour Ă  pause_menu") + continue + + if config.menu_state == "confirm_clear_history": + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + logger.debug(f"ÉvĂ©nement transmis Ă  handle_controls dans confirm_clear_history: {event.type}") + continue + + if config.menu_state == "redownload_game_cache": + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + logger.debug(f"ÉvĂ©nement transmis Ă  handle_controls dans redownload_game_cache: {event.type}") + continue + + if config.menu_state == "extension_warning": + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + if action == "confirm": + if config.pending_download and config.extension_confirm_selection == 0: # Oui + url, platform, game_name, is_zip_non_supported = config.pending_download + logger.debug(f"TĂ©lĂ©chargement confirmĂ© aprĂšs avertissement: {game_name} pour {platform} depuis {url}") + task_id = str(pygame.time.get_ticks()) + config.history.append({ + "platform": platform, + "game_name": game_name, + "status": "downloading", + "progress": 0, + "url": url, + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + config.current_history_item = len(config.history) - 1 + save_history(config.history) + config.download_tasks[task_id] = ( + asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id)), + url, game_name, platform + ) + config.menu_state = "history" + config.pending_download = None + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement dĂ©marrĂ© pour {game_name}, task_id={task_id}") + elif config.extension_confirm_selection == 1: # Non + config.menu_state = config.previous_menu_state + config.pending_download = None + config.needs_redraw = True + logger.debug("TĂ©lĂ©chargement annulĂ©, retour Ă  l'Ă©tat prĂ©cĂ©dent") + continue + + if config.menu_state in ["platform", "game", "error", "confirm_exit", "download_progress", "download_result", "history"]: + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + if action == "quit": + running = False + logger.debug("Action quit dĂ©tectĂ©e, arrĂȘt de l'application") + elif action == "download" and config.menu_state == "game" and config.filtered_games: + game = config.filtered_games[config.current_game] + game_name = game[0] if isinstance(game, (list, tuple)) else game + platform = config.platforms[config.current_platform]["name"] # Utiliser le nom de la plateforme + url = game[1] if isinstance(game, (list, tuple)) and len(game) > 1 else None + if url: + logger.debug(f"VĂ©rification pour {game_name}, URL: {url}") + # Ajouter une entrĂ©e temporaire Ă  l'historique + config.history.append({ + "platform": platform, + "game_name": game_name, + "status": "downloading", + "progress": 0, + "url": url, + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + config.current_history_item = len(config.history) - 1 # SĂ©lectionner l'entrĂ©e en cours + if is_1fichier_url(url): + if not config.API_KEY_1FICHIER: + config.previous_menu_state = config.menu_state + config.menu_state = "error" + config.error_message = ( + "Attention il faut renseigner sa clĂ© API (premium only) dans le fichier /userdata/saves/ports/rgsx/1fichierAPI.txt" + ) + # Mettre Ă  jour l'entrĂ©e temporaire avec l'erreur + config.history[-1]["status"] = "Erreur" + config.history[-1]["progress"] = 0 + config.history[-1]["message"] = "Erreur API : ClĂ© API 1fichier absente" + save_history(config.history) + config.needs_redraw = True + logger.error("ClĂ© API 1fichier absente") + config.pending_download = None + continue + is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name) + if not is_supported: + config.pending_download = (url, platform, game_name, is_zip_non_supported) + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non reconnue pour lien 1fichier, passage Ă  extension_warning pour {game_name}") + # Supprimer l'entrĂ©e temporaire si erreur + config.history.pop() + else: + config.previous_menu_state = config.menu_state + logger.debug(f"Previous menu state dĂ©fini: {config.previous_menu_state}") + # Lancer le tĂ©lĂ©chargement dans une tĂąche asynchrone + task_id = str(pygame.time.get_ticks()) + config.download_tasks[task_id] = ( + asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported)), + url, game_name, platform + ) + config.menu_state = "history" # Passer Ă  l'historique + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement 1fichier dĂ©marrĂ© pour {game_name}, passage Ă  l'historique") + else: + is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name) + if not is_supported: + config.pending_download = (url, platform, game_name, is_zip_non_supported) + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non reconnue, passage Ă  extension_warning pour {game_name}") + # Supprimer l'entrĂ©e temporaire si erreur + config.history.pop() + else: + config.previous_menu_state = config.menu_state + logger.debug(f"Previous menu state dĂ©fini: {config.previous_menu_state}") + # Lancer le tĂ©lĂ©chargement dans une tĂąche asynchrone + task_id = str(pygame.time.get_ticks()) + config.download_tasks[task_id] = ( + asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported)), + url, game_name, platform + ) + config.menu_state = "history" # Passer Ă  l'historique + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement dĂ©marrĂ© pour {game_name}, passage Ă  l'historique") + elif action == "redownload" and config.menu_state == "history" and config.history: + entry = config.history[config.current_history_item] + platform = entry["platform"] + game_name = entry["game_name"] + for game in config.games: + if game[0] == game_name and config.platforms[config.current_platform] == platform: + url = game[1] + logger.debug(f"VĂ©rification pour retĂ©lĂ©chargement de {game_name}, URL: {url}") + if is_1fichier_url(url): + if not config.API_KEY_1FICHIER: + config.previous_menu_state = config.menu_state + config.menu_state = "error" + config.error_message = ( + f"Attention il faut renseigner sa clĂ© API (premium only) dans le fichier {os.path.join(config.SAVE_FOLDER, '1fichierAPI.txt')}" + ) + config.needs_redraw = True + logger.error("ClĂ© API 1fichier absente") + config.pending_download = None + continue + is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name) + if not is_supported: + config.pending_download = (url, platform, game_name, is_zip_non_supported) + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non reconnue pour lien 1fichier, passage Ă  extension_warning pour {game_name}") + else: + config.previous_menu_state = config.menu_state + logger.debug(f"Previous menu state dĂ©fini: {config.previous_menu_state}") + success, message = download_from_1fichier(url, platform, game_name, is_zip_non_supported) + config.download_result_message = message + config.download_result_error = not success + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() + config.pending_download = None + config.needs_redraw = True + logger.debug(f"RetĂ©lĂ©chargement 1fichier terminĂ© pour {game_name}, succĂšs={success}, message={message}") + else: + is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name) + if not is_supported: + config.pending_download = (url, platform, game_name, is_zip_non_supported) + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non reconnue pour retĂ©lĂ©chargement, passage Ă  extension_warning pour {game_name}") + else: + config.previous_menu_state = config.menu_state + logger.debug(f"Previous menu state dĂ©fini: {config.previous_menu_state}") + success, message = download_rom(url, platform, game_name, is_zip_non_supported) + config.download_result_message = message + config.download_result_error = not success + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() + config.pending_download = None + config.needs_redraw = True + logger.debug(f"RetĂ©lĂ©chargement terminĂ© pour {game_name}, succĂšs={success}, message={message}") + break + + + + + # Gestion des tĂ©lĂ©chargements + if config.download_tasks: + for task_id, (task, url, game_name, platform) in list(config.download_tasks.items()): + if task.done(): + try: + success, message = await task + if "http" in message: + message = message.split("https://")[0].strip() + for entry in config.history: + if entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["status"] = "Download_OK" if success else "Erreur" + entry["progress"] = 100 if success else 0 + entry["message"] = message + save_history(config.history) + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement terminĂ©: {game_name}, succĂšs={success}, message={message}, task_id={task_id}") + break + config.download_result_message = message + config.download_result_error = not success + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() + config.pending_download = None + config.needs_redraw = True + del config.download_tasks[task_id] + except Exception as e: + message = f"Erreur lors du tĂ©lĂ©chargement: {str(e)}" + if "http" in message: + message = message.split("https://")[0].strip() + for entry in config.history: + if entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["status"] = "Erreur" + entry["progress"] = 0 + entry["message"] = message + save_history(config.history) + config.needs_redraw = True + logger.debug(f"Erreur tĂ©lĂ©chargement: {game_name}, message={message}, task_id={task_id}") + break + config.download_result_message = message + config.download_result_error = True + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() + config.pending_download = None + config.needs_redraw = True + del config.download_tasks[task_id] + else: + # Traiter les mises Ă  jour de progression + + progress_queue = queue.Queue() + while not progress_queue.empty(): + data = progress_queue.get() + # logger.debug(f"Progress queue data received: {data}, task_id={task_id}") + if len(data) != 3 or data[0] != task_id: # Ignorer les donnĂ©es d'une autre tĂąche + logger.debug(f"Ignoring queue data for task_id={data[0]}, expected={task_id}") + continue + if isinstance(data[1], bool): # Fin du tĂ©lĂ©chargement + success, message = data[1], data[2] + for entry in config.history: + if entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["status"] = "Download_OK" if success else "Erreur" + entry["progress"] = 100 if success else 0 + entry["message"] = message + save_history(config.history) + config.needs_redraw = True + logger.debug(f"Final update in history: status={entry['status']}, progress={entry['progress']}%, message={message}, task_id={task_id}") + break + else: + downloaded, total_size = data[1], data[2] + progress = (downloaded / total_size * 100) if total_size > 0 else 0 + for entry in config.history: + if entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["progress"] = progress + entry["status"] = "TĂ©lĂ©chargement" + config.needs_redraw = True + # logger.debug(f"Progress updated in history: {progress:.1f}% for {game_name}, task_id={task_id}") + break + config.download_result_message = message + config.download_result_error = True + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() + config.pending_download = None + config.needs_redraw = True + del config.download_tasks[task_id] + + # Gestion de la fin du popup download_result + if config.menu_state == "download_result" and current_time - config.download_result_start_time > 3000: + config.menu_state = "history" # Rester dans l'historique aprĂšs le popup + config.download_progress.clear() + config.pending_download = None + config.needs_redraw = True + logger.debug(f"Fin popup download_result, retour Ă  history") + + # Affichage + if config.needs_redraw: + draw_gradient(screen, THEME_COLORS["background_top"], THEME_COLORS["background_bottom"]) + + + if config.menu_state == "controls_mapping": + # Ne rien faire ici, la gestion est faite dans la section spĂ©cifique + pass + elif config.menu_state == "loading": + draw_loading_screen(screen) + elif config.menu_state == "error": + draw_error_screen(screen) + elif config.menu_state == "update_result": + draw_popup_result_download(screen, config.update_result_message, config.update_result_error) + elif config.menu_state == "platform": + draw_platform_grid(screen) + elif config.menu_state == "game": + if not config.search_mode: + draw_game_list(screen) + if config.search_mode: + draw_game_list(screen) + if config.is_non_pc: + draw_virtual_keyboard(screen) + elif config.menu_state == "download_progress": + draw_progress_screen(screen) + elif config.menu_state == "download_result": + draw_popup_result_download(screen, config.download_result_message, config.download_result_error) + elif config.menu_state == "confirm_exit": + draw_confirm_dialog(screen) + elif config.menu_state == "extension_warning": + draw_extension_warning(screen) + elif config.menu_state == "pause_menu": + draw_pause_menu(screen, config.selected_option) + logger.debug("Rendu de draw_pause_menu") + elif config.menu_state == "controls_help": + draw_controls_help(screen, config.previous_menu_state) + elif config.menu_state == "history": + draw_history_list(screen) + # logger.debug("Screen updated with draw_history_list") + elif config.menu_state == "confirm_clear_history": + draw_clear_history_dialog(screen) + elif config.menu_state == "redownload_game_cache": + draw_redownload_game_cache_dialog(screen) + elif config.menu_state == "restart_popup": + draw_popup(screen) + elif config.menu_state == "language_select": + draw_language_menu(screen) + # Ajout de log pour dĂ©boguer + logger.debug(f"Affichage du sĂ©lecteur de langue, index={config.selected_language_index}") + else: + config.menu_state = "platform" + draw_platform_grid(screen) + config.needs_redraw = True + logger.error(f"État de menu non valide dĂ©tectĂ©: {config.menu_state}, retour Ă  platform") + draw_controls(screen, config.menu_state) + pygame.display.flip() + + config.needs_redraw = False + # logger.debug("Screen flipped with pygame.display.flip()") + + # Gestion de l'Ă©tat controls_mapping + if config.menu_state == "controls_mapping": + logger.debug("Avant appel de map_controls") + try: + # VĂ©rifier si le fichier de contrĂŽles existe dĂ©jĂ  + controls_file_exists = os.path.exists(config.CONTROLS_CONFIG_PATH) + logger.debug(f"VĂ©rification du fichier controls.json: {controls_file_exists} Ă  {config.CONTROLS_CONFIG_PATH}") + + if controls_file_exists: + # Si le fichier existe dĂ©jĂ , passer directement Ă  l'Ă©tat loading + config.menu_state = "loading" + logger.debug("Fichier controls.json existe dĂ©jĂ , passage direct Ă  l'Ă©tat loading") + config.needs_redraw = True + else: + # Forcer l'affichage de l'interface de mappage des contrĂŽles + action = ACTIONS[0] + draw_controls_mapping(screen, action, None, True, 0.0) + pygame.display.flip() + logger.debug("Interface de mappage des contrĂŽles affichĂ©e") + + # Appeler map_controls pour gĂ©rer la configuration + success = map_controls(screen) + logger.debug(f"map_controls terminĂ©, succĂšs={success}") + if success: + config.controls_config = load_controls_config() + # Toujours passer Ă  l'Ă©tat loading aprĂšs la configuration des contrĂŽles + config.menu_state = "loading" + logger.debug("Passage Ă  l'Ă©tat loading aprĂšs mappage") + config.needs_redraw = True + else: + config.menu_state = "error" + config.error_message = "Échec du mappage des contrĂŽles" + config.needs_redraw = True + logger.debug("Échec du mappage, passage Ă  l'Ă©tat error") + except Exception as e: + logger.error(f"Erreur lors de l'appel de map_controls : {str(e)}") + config.menu_state = "error" + config.error_message = f"Erreur dans map_controls: {str(e)}" + config.needs_redraw = True + + # Gestion de l'Ă©tat loading + elif config.menu_state == "loading": + if loading_step == "none": + loading_step = "test_internet" + config.current_loading_system = "Test de connexion..." + config.loading_progress = 0.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + elif loading_step == "test_internet": + logger.debug("ExĂ©cution de test_internet()") + if test_internet(): + loading_step = "check_ota" + config.current_loading_system = "Verification Mise Ă  jour en cours... Patientez..." + config.loading_progress = 20.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + else: + config.menu_state = "error" + config.error_message = "Pas de connexion Internet. VĂ©rifiez votre rĂ©seau." + config.needs_redraw = True + logger.debug(f"Erreur : {config.error_message}") + elif loading_step == "check_ota": + logger.debug("ExĂ©cution de check_for_updates()") + success, message = await check_for_updates() + logger.debug(f"RĂ©sultat de check_for_updates : success={success}, message={message}") + if not success: + config.menu_state = "error" + config.error_message = message + config.needs_redraw = True + logger.debug(f"Erreur OTA : {message}") + else: + loading_step = "check_data" + config.current_loading_system = "TĂ©lĂ©chargement des jeux et images ..." + config.loading_progress = 50.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + elif loading_step == "check_data": + games_data_dir = os.path.join(config.APP_FOLDER, "games") + is_data_empty = not os.path.exists(games_data_dir) or not any(os.scandir(games_data_dir)) + if is_data_empty: + config.current_loading_system = "TĂ©lĂ©chargement du Dossier Data initial..." + config.loading_progress = 30.0 + config.needs_redraw = True + logger.debug("Dossier Data vide, dĂ©but du tĂ©lĂ©chargement du ZIP") + try: + zip_path = os.path.join(config.APP_FOLDER, "data_download.zip") + headers = {'User-Agent': 'Mozilla/5.0'} + with requests.get(OTA_data_ZIP, stream=True, headers=headers, timeout=30) as response: + response.raise_for_status() + total_size = int(response.headers.get('content-length', 0)) + logger.debug(f"Taille totale du ZIP : {total_size} octets") + downloaded = 0 + os.makedirs(os.path.dirname(zip_path), exist_ok=True) + with open(zip_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + config.download_progress[OTA_data_ZIP] = { + "downloaded_size": downloaded, + "total_size": total_size, + "status": "TĂ©lĂ©chargement", + "progress_percent": (downloaded / total_size * 100) if total_size > 0 else 0 + } + config.loading_progress = 15.0 + (35.0 * downloaded / total_size) if total_size > 0 else 15.0 + config.needs_redraw = True + await asyncio.sleep(0) + logger.debug(f"ZIP tĂ©lĂ©chargĂ© : {zip_path}") + + config.current_loading_system = "Extraction du Dossier Data initial..." + config.loading_progress = 60.0 + config.needs_redraw = True + dest_dir = config.APP_FOLDER + success, message = extract_zip_data(zip_path, dest_dir, OTA_data_ZIP) + if success: + logger.debug(f"Extraction rĂ©ussie : {message}") + config.loading_progress = 70.0 + config.needs_redraw = True + else: + raise Exception(f"Échec de l'extraction : {message}") + except Exception as e: + logger.error(f"Erreur lors du tĂ©lĂ©chargement/extraction du Dossier Data : {str(e)}") + config.menu_state = "error" + config.error_message = f"Échec du tĂ©lĂ©chargement/extraction du Dossier Data : {str(e)}" + config.needs_redraw = True + loading_step = "load_sources" + if os.path.exists(zip_path): + os.remove(zip_path) + continue + if os.path.exists(zip_path): + os.remove(zip_path) + logger.debug(f"Fichier ZIP {zip_path} supprimĂ©") + loading_step = "load_sources" + config.current_loading_system = "Chargement des systĂšmes..." + config.loading_progress = 80.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + else: + loading_step = "load_sources" + config.current_loading_system = "Chargement des systĂšmes..." + config.loading_progress = 80.0 + config.needs_redraw = True + logger.debug(f"Dossier Data non vide, passage Ă  {loading_step}") + elif loading_step == "load_sources": + sources = load_sources() + if not sources: + config.menu_state = "error" + config.error_message = "Échec du chargement de sources.json" + config.needs_redraw = True + logger.debug("Erreur : Échec du chargement de sources.json") + else: + config.menu_state = "platform" + config.loading_progress = 100.0 + config.current_loading_system = "" + config.needs_redraw = True + logger.debug(f"Fin chargement, passage Ă  platform, progress={config.loading_progress}") + + # Gestion de l'Ă©tat de transition + if config.transition_state == "to_game": + config.transition_progress += 1 + if config.transition_progress >= config.transition_duration: + config.menu_state = "game" + config.transition_state = "idle" + config.transition_progress = 0.0 + config.needs_redraw = True + logger.debug("Transition terminĂ©e, passage Ă  game") + + config.last_frame_time = current_time + clock.tick(60) + await asyncio.sleep(0.01) + + pygame.mixer.music.stop() + pygame.quit() + logger.debug("Application terminĂ©e") + +if platform.system() == "Emscripten": + asyncio.ensure_future(main()) +else: + if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/assets/Pixel-UniCode.ttf b/assets/Pixel-UniCode.ttf new file mode 100644 index 0000000..5b4d3cb Binary files /dev/null and b/assets/Pixel-UniCode.ttf differ diff --git a/assets/music/8bit.mp3 b/assets/music/8bit.mp3 new file mode 100644 index 0000000..93107de Binary files /dev/null and b/assets/music/8bit.mp3 differ diff --git a/assets/music/90s.mp3 b/assets/music/90s.mp3 new file mode 100644 index 0000000..91d9907 Binary files /dev/null and b/assets/music/90s.mp3 differ diff --git a/assets/music/aquatic_ambience.mp3 b/assets/music/aquatic_ambience.mp3 new file mode 100644 index 0000000..d5578ae Binary files /dev/null and b/assets/music/aquatic_ambience.mp3 differ diff --git a/assets/music/fantasia.mp3 b/assets/music/fantasia.mp3 new file mode 100644 index 0000000..b104334 Binary files /dev/null and b/assets/music/fantasia.mp3 differ diff --git a/assets/music/game_mode.mp3 b/assets/music/game_mode.mp3 new file mode 100644 index 0000000..a81b62f Binary files /dev/null and b/assets/music/game_mode.mp3 differ diff --git a/assets/music/pixel_racer.mp3 b/assets/music/pixel_racer.mp3 new file mode 100644 index 0000000..ba6fcc7 Binary files /dev/null and b/assets/music/pixel_racer.mp3 differ diff --git a/assets/music/return_8bit.mp3 b/assets/music/return_8bit.mp3 new file mode 100644 index 0000000..f271737 Binary files /dev/null and b/assets/music/return_8bit.mp3 differ diff --git a/assets/music/stranger.mp3 b/assets/music/stranger.mp3 new file mode 100644 index 0000000..a321333 Binary files /dev/null and b/assets/music/stranger.mp3 differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..d1a0b6f --- /dev/null +++ b/config.py @@ -0,0 +1,148 @@ +import pygame # type: ignore +import os +import logging + +logger = logging.getLogger(__name__) + +# Version actuelle de l'application +app_version = "1.9.7.0" + +# Langue par dĂ©faut +current_language = "fr" + + +# URL +OTA_SERVER_URL = "https://retrogamesets.fr/softs" +OTA_VERSION_ENDPOINT = f"{OTA_SERVER_URL}/version.json" +OTA_UPDATE_ZIP = f"{OTA_SERVER_URL}/RGSX.zip" +OTA_data_ZIP = f"{OTA_SERVER_URL}/rgsx-data.zip" + +# Chemins de base +APP_FOLDER = "/userdata/roms/ports/RGSX" +SAVE_FOLDER = "/userdata/saves/ports/rgsx" +UPDATE_FOLDER = f"{APP_FOLDER}/update" +CONTROLS_CONFIG_PATH = os.path.join(SAVE_FOLDER, "controls.json") +HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json") +LANGUAGE_CONFIG_PATH = os.path.join(SAVE_FOLDER, "language.json") +JSON_EXTENSIONS = os.path.join(APP_FOLDER, "rom_extensions.json") + + +# Constantes pour la rĂ©pĂ©tition automatique dans pause_menu +REPEAT_DELAY = 350 # DĂ©lai initial avant rĂ©pĂ©tition (ms) - augmentĂ© pour Ă©viter les doubles actions +REPEAT_INTERVAL = 120 # Intervalle entre rĂ©pĂ©titions (ms) - ajustĂ© pour une navigation plus contrĂŽlĂ©e +REPEAT_ACTION_DEBOUNCE = 150 # DĂ©lai anti-rebond pour rĂ©pĂ©titions (ms) - augmentĂ© pour Ă©viter les doubles actions + + +# Variables d'Ă©tat +platforms = [] +current_platform = 0 +platform_names = {} # {platform_id: platform_name} +games = [] +current_game = 0 +menu_state = "popup" +confirm_choice = False +scroll_offset = 0 +visible_games = 15 +popup_start_time = 0 +last_progress_update = 0 +needs_redraw = True +transition_state = "idle" +transition_progress = 0.0 +transition_duration = 18 +games_count = {} + +# Variables pour la sĂ©lection de langue +selected_language_index = 0 + +loading_progress = 0.0 +current_loading_system = "" +error_message = "" +repeat_action = None +repeat_start_time = 0 +repeat_last_action = 0 +repeat_key = None +filtered_games = [] +search_mode = False +search_query = "" +filter_active = False +extension_confirm_selection = 0 +pending_download = None +controls_config = {} +selected_option = 0 +previous_menu_state = None +history = [] # Liste des entrĂ©es d'historique avec platform, game_name, status, url, progress, message, timestamp +download_progress = {} +download_tasks = {} # Dictionnaire pour les tĂąches de tĂ©lĂ©chargement +download_result_message = "" +download_result_error = False +download_result_start_time = 0 +pending_download = None +needs_redraw = False +current_history_item = 0 +history_scroll_offset = 0 # Offset pour le dĂ©filement de l'historique +visible_history_items = 15 # Nombre d'Ă©lĂ©ments d'historique visibles (ajustĂ© dynamiquement) +confirm_clear_selection = 0 # confirmation clear historique +last_state_change_time = 0 # Temps du dernier changement d'Ă©tat pour debounce +debounce_delay = 200 # DĂ©lai de debounce en millisecondes +platform_dicts = [] # Liste des dictionnaires de plateformes +selected_key = (0, 0) # Position du curseur dans le clavier virtuel +is_non_pc = True # Indicateur pour plateforme non-PC (par exemple, console) +redownload_confirm_selection = 0 # SĂ©lection pour la confirmation de redownload +popup_message = "" # Message Ă  afficher dans les popups +popup_timer = 0 # Temps restant pour le popup en millisecondes (0 = inactif) +last_frame_time = pygame.time.get_ticks() + + +GRID_COLS = 3 # Number of columns in the platform grid +GRID_ROWS = 4 # Number of rows in the platform grid + +# RĂ©solution de l'Ă©cran fallback +# UtilisĂ©e si la rĂ©solution dĂ©finie dĂ©passe les capacitĂ©s de l'Ă©cran +SCREEN_WIDTH = 800 +"""Largeur de l'Ă©cran en pixels.""" +SCREEN_HEIGHT = 600 +"""Hauteur de l'Ă©cran en pixels.""" + +# Polices +FONT = None +"""Police par dĂ©faut pour l'affichage, initialisĂ©e via init_font().""" +progress_font = None +"""Police pour l'affichage de la progression.""" +title_font = None +"""Police pour les titres.""" +search_font = None +"""Police pour la recherche.""" +small_font = None +"""Police pour les petits textes.""" + +def init_font(): + """Initialise les polices aprĂšs pygame.init().""" + global FONT, progress_font, title_font, search_font, small_font + try: + FONT = pygame.font.Font(None, 36) + progress_font = pygame.font.Font(None, 28) + title_font = pygame.font.Font(None, 48) + search_font = pygame.font.Font(None, 36) + small_font = pygame.font.Font(None, 24) + logger.debug("Polices initialisĂ©es avec succĂšs") + # amazonq-ignore-next-line + except pygame.error as e: + logger.error(f"Erreur lors de l'initialisation des polices : {e}") + FONT = None + progress_font = None + title_font = None + search_font = None + small_font = None + +def validate_resolution(): + """Valide la rĂ©solution de l'Ă©cran par rapport aux capacitĂ©s de l'Ă©cran.""" + display_info = pygame.display.Info() + if SCREEN_WIDTH > display_info.current_w or SCREEN_HEIGHT > display_info.current_h: + logger.warning(f"RĂ©solution {SCREEN_WIDTH}x{SCREEN_HEIGHT} dĂ©passe les limites de l'Ă©cran") + return display_info.current_w, display_info.current_h + return SCREEN_WIDTH, SCREEN_HEIGHT + + + + +API_KEY_1FICHIER = "" # Initialisation de la variable globale pour la clĂ© API diff --git a/controls.py b/controls.py new file mode 100644 index 0000000..f460de1 --- /dev/null +++ b/controls.py @@ -0,0 +1,1079 @@ +import shutil +import pygame # type: ignore +import config +# Constantes pour la rĂ©pĂ©tition automatique - importĂ©es de config.py +from config import REPEAT_DELAY, REPEAT_INTERVAL, REPEAT_ACTION_DEBOUNCE +from config import CONTROLS_CONFIG_PATH , GRID_COLS, GRID_ROWS +import asyncio +import json +import os +from display import draw_validation_transition +from network import download_rom, download_from_1fichier, is_1fichier_url +from utils import load_games, check_extension_before_download, is_extension_supported, load_extensions_json, sanitize_filename +from history import load_history, clear_history, add_to_history, save_history +import logging +from language import _ # Import de la fonction de traduction + +logger = logging.getLogger(__name__) + + +# DĂ©lais spĂ©cifiques pour les contrĂŽles +JOYHAT_DEBOUNCE = 150 # DĂ©lai anti-rebond pour JOYHATMOTION (ms) +JOYAXIS_DEBOUNCE = 150 # DĂ©lai anti-rebond pour JOYAXISMOTION (ms) + +# Variables globales pour la rĂ©pĂ©tition +key_states = {} # Dictionnaire pour suivre l'Ă©tat des touches + +# Liste des Ă©tats valides +VALID_STATES = [ + "platform", "game", "download_progress", "download_result", "confirm_exit", + "extension_warning", "pause_menu", "controls_help", "history", "controls_mapping", + "redownload_game_cache", "restart_popup", "error", "loading", "confirm_clear_history", + "language_select" +] + +def validate_menu_state(state): + valid_states = ["platform", "game", "download_progress", "download_result", "confirm_exit", "extension_warning", "pause_menu", "controls_help", "controls_mapping", "redownload_game_cache", "restart_popup", "confirm_clear_history", "language_select"] + if state not in VALID_STATES: + logger.debug(f"État invalide {state}, retour Ă  platform") + return "platform" + if state == "history": # Éviter de revenir Ă  history + logger.debug(f"État history non autorisĂ© comme previous_menu_state, retour Ă  platform") + return "platform" + return state + + +def load_controls_config(path=CONTROLS_CONFIG_PATH): + """Charge la configuration des contrĂŽles depuis un fichier JSON.""" + try: + with open(path, "r") as f: + config_data = json.load(f) + # VĂ©rifier les actions nĂ©cessaires + required_actions = ["confirm", "cancel", "up", "down"] + for action in required_actions: + if action not in config_data: + logger.warning(f"Action {action} manquante dans {path}, utilisation de la valeur par dĂ©faut") + config_data[action] = { + "type": "key", + "value": { + "confirm": {"type": "key", "value": pygame.K_RETURN}, + "cancel": {"type": "key", "value": pygame.K_ESCAPE}, + "left": {"type": "key", "value": pygame.K_LEFT}, + "right": {"type": "key", "value": pygame.K_RIGHT}, + "up": {"type": "key", "value": pygame.K_UP}, + "down": {"type": "key", "value": pygame.K_DOWN}, + "start": {"type": "key", "value": pygame.K_p}, + "progress": {"type": "key", "value": pygame.K_x}, + "history": {"type": "key", "value": pygame.K_h}, + "page_up": {"type": "key", "value": pygame.K_PAGEUP}, + "page_down": {"type": "key", "value": pygame.K_PAGEDOWN}, + "filter": {"type": "key", "value": pygame.K_f}, + "delete": {"type": "key", "value": pygame.K_BACKSPACE}, + "space": {"type": "key", "value": pygame.K_SPACE} + }[action] + } + return config_data + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.error(f"Erreur lors de la lecture de {path} : {e}, utilisation de la configuration par dĂ©faut") + return { + "confirm": {"type": "key", "value": pygame.K_RETURN}, + "cancel": {"type": "key", "value": pygame.K_ESCAPE}, + "left": {"type": "key", "value": pygame.K_LEFT}, + "right": {"type": "key", "value": pygame.K_RIGHT}, + "up": {"type": "key", "value": pygame.K_UP}, + "down": {"type": "key", "value": pygame.K_DOWN}, + "start": {"type": "key", "value": pygame.K_p}, + "progress": {"type": "key", "value": pygame.K_x}, + "history": {"type": "key", "value": pygame.K_h}, + "page_up": {"type": "key", "value": pygame.K_PAGEUP}, + "page_down": {"type": "key", "value": pygame.K_PAGEDOWN}, + "filter": {"type": "key", "value": pygame.K_f}, + "delete": {"type": "key", "value": pygame.K_BACKSPACE}, + "space": {"type": "key", "value": pygame.K_SPACE} + } + +# Fonction pour vĂ©rifier si un Ă©vĂ©nement correspond Ă  une action +def is_input_matched(event, action_name): + if not config.controls_config.get(action_name): + return False + mapping = config.controls_config[action_name] + input_type = mapping["type"] + input_value = mapping["value"] + + # Convertir input_value en tuple si c'est une liste (pour JOYHATMOTION) + if input_type == "hat" and isinstance(input_value, list): + input_value = tuple(input_value) + + if input_type == "key" and event.type == pygame.KEYDOWN: + return event.key == input_value + elif input_type == "button" and event.type == pygame.JOYBUTTONDOWN: + return event.button == input_value + elif input_type == "axis" and event.type == pygame.JOYAXISMOTION: + axis, direction = input_value + return event.axis == axis and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == direction + elif input_type == "hat" and event.type == pygame.JOYHATMOTION: + return event.value == input_value + elif input_type == "mouse" and event.type == pygame.MOUSEBUTTONDOWN: + return event.button == input_value + return False + +def handle_controls(event, sources, joystick, screen): + """GĂšre un Ă©vĂ©nement clavier/joystick/souris et la rĂ©pĂ©tition automatique. + Retourne 'quit', 'download', 'redownload', ou None.""" + action = None + current_time = pygame.time.get_ticks() + global _ + # Valider previous_menu_state avant tout traitement + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + + # Debounce gĂ©nĂ©ral + if current_time - config.last_state_change_time < config.debounce_delay: + return action + + # --- CLAVIER, MANETTE, SOURIS --- + if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN, pygame.JOYAXISMOTION, pygame.JOYHATMOTION, pygame.MOUSEBUTTONDOWN): + # DĂ©bouncer les Ă©vĂ©nements JOYHATMOTION + if event.type == pygame.JOYHATMOTION: + if event.value == (0, 0): # Ignorer les relĂąchements + # Mettre Ă  jour l'Ă©tat des touches directionnelles + for action in ["up", "down", "left", "right"]: + update_key_state(action, False) + return action + + # Quitter l'appli + if event.type == pygame.QUIT: + logger.debug("ÉvĂ©nement pygame.QUIT dĂ©tectĂ©") + return "quit" + + # Menu pause + if is_input_matched(event, "start") and config.menu_state not in ("pause_menu", "controls_help", "controls_mapping", "redownload_game_cache"): + config.previous_menu_state = config.menu_state + config.menu_state = "pause_menu" + config.selected_option = 0 + config.needs_redraw = True + logger.debug(f"Passage Ă  pause_menu depuis {config.previous_menu_state}") + return action + + # Erreur + if config.menu_state == "error": + if is_input_matched(event, "confirm"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug("Sortie du menu erreur avec Confirm") + + #Plateformes + elif config.menu_state == "platform": + systems_per_page = GRID_COLS * GRID_ROWS + max_index = min(systems_per_page, len(config.platforms) - config.current_page * systems_per_page) - 1 + current_grid_index = config.selected_platform - config.current_page * systems_per_page + row = current_grid_index // GRID_COLS + col = current_grid_index % GRID_COLS + + # Espace rĂ©servĂ© pour des fonctions helper si nĂ©cessaire + + if is_input_matched(event, "down"): + # Navigation vers le bas avec gestion des limites de page + if current_grid_index + GRID_COLS <= max_index: + # DĂ©placement normal vers le bas + config.selected_platform += GRID_COLS + update_key_state("down", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif (config.current_page + 1) * systems_per_page < len(config.platforms): + # Passage Ă  la page suivante si on est en bas de la grille + config.current_page += 1 + new_row = 0 # PremiĂšre ligne de la nouvelle page + config.selected_platform = config.current_page * systems_per_page + new_row * GRID_COLS + col + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + update_key_state("down", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif is_input_matched(event, "up"): + # Navigation vers le haut avec gestion des limites de page + if current_grid_index - GRID_COLS >= 0: + # DĂ©placement normal vers le haut + config.selected_platform -= GRID_COLS + update_key_state("up", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif config.current_page > 0: + # Passage Ă  la page prĂ©cĂ©dente si on est en haut de la grille + config.current_page -= 1 + new_row = GRID_ROWS - 1 # DerniĂšre ligne de la page prĂ©cĂ©dente + config.selected_platform = config.current_page * systems_per_page + new_row * GRID_COLS + col + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + update_key_state("up", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif is_input_matched(event, "left"): + if col > 0: + # DĂ©placement normal vers la gauche + config.selected_platform -= 1 + update_key_state("left", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif config.current_page > 0: + # Passage Ă  la page prĂ©cĂ©dente si on est Ă  la premiĂšre colonne + config.current_page -= 1 + config.selected_platform = config.current_page * systems_per_page + row * GRID_COLS + (GRID_COLS - 1) + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + update_key_state("left", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif is_input_matched(event, "right"): + if col < GRID_COLS - 1 and current_grid_index < max_index: + # DĂ©placement normal vers la droite + config.selected_platform += 1 + update_key_state("right", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif (config.current_page + 1) * systems_per_page < len(config.platforms): + # Passage Ă  la page suivante si on est Ă  la derniĂšre colonne + config.current_page += 1 + config.selected_platform = config.current_page * systems_per_page + row * GRID_COLS + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + update_key_state("right", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + elif is_input_matched(event, "page_down"): + # Navigation rapide vers la page suivante + if (config.current_page + 1) * systems_per_page < len(config.platforms): + config.current_page += 1 + config.selected_platform = config.current_page * systems_per_page + row * GRID_COLS + col + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + # RĂ©initialiser la rĂ©pĂ©tition pour Ă©viter des comportements inattendus + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + elif is_input_matched(event, "page_up"): + # Navigation rapide vers la page prĂ©cĂ©dente + if config.current_page > 0: + config.current_page -= 1 + config.selected_platform = config.current_page * systems_per_page + row * GRID_COLS + col + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + # RĂ©initialiser la rĂ©pĂ©tition pour Ă©viter des comportements inattendus + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + elif is_input_matched(event, "progress"): + if config.download_tasks: + config.menu_state = "download_progress" + config.needs_redraw = True + logger.debug("Retour Ă  download_progress depuis platform") + elif is_input_matched(event, "history"): + config.menu_state = "history" + config.needs_redraw = True + logger.debug("Ouverture history depuis platform") + elif is_input_matched(event, "confirm"): + if config.platforms: + config.current_platform = config.selected_platform + config.games = load_games(config.platforms[config.current_platform]) + config.filtered_games = config.games + config.filter_active = False + config.current_game = 0 + config.scroll_offset = 0 + draw_validation_transition(screen, config.current_platform) + config.menu_state = "game" + config.needs_redraw = True + #logger.debug(f"Plateforme sĂ©lectionnĂ©e: {config.platforms[config.current_platform]}, {len(config.games)} jeux chargĂ©s") + elif is_input_matched(event, "cancel"): + config.menu_state = "confirm_exit" + config.confirm_selection = 0 + config.needs_redraw = True + + # Jeux + elif config.menu_state == "game": + games = config.filtered_games if config.filter_active or config.search_mode else config.games + if config.search_mode and config.is_non_pc: + keyboard_layout = [ + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + ['A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M'], + ['W', 'X', 'C', 'V', 'B', 'N'] + ] + row, col = config.selected_key + max_row = len(keyboard_layout) - 1 + max_col = len(keyboard_layout[row]) - 1 + if is_input_matched(event, "up"): + if row > 0: + config.selected_key = (row - 1, min(col, len(keyboard_layout[row - 1]) - 1)) + config.repeat_action = "up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "down"): + if row < max_row: + config.selected_key = (row + 1, min(col, len(keyboard_layout[row + 1]) - 1)) + config.repeat_action = "down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "left"): + if col > 0: + config.selected_key = (row, col - 1) + config.repeat_action = "left" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "right"): + if col < max_col: + config.selected_key = (row, col + 1) + config.repeat_action = "right" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "confirm"): + config.search_query += keyboard_layout[row][col] + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug(f"Recherche mise Ă  jour: query={config.search_query}, jeux filtrĂ©s={len(config.filtered_games)}") + elif is_input_matched(event, "delete"): + if config.search_query: + config.search_query = config.search_query[:-1] + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + #logger.debug(f"Suppression caractĂšre: query={config.search_query}, jeux filtrĂ©s={len(config.filtered_games)}") + elif is_input_matched(event, "space"): + config.search_query += " " + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + #logger.debug(f"Espace ajoutĂ©: query={config.search_query}, jeux filtrĂ©s={len(config.filtered_games)}") + elif is_input_matched(event, "cancel"): + config.search_mode = False + config.search_query = "" + config.selected_key = (0, 0) + config.filtered_games = config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug("Sortie du mode recherche") + elif is_input_matched(event, "filter"): + config.search_mode = False + config.filter_active = bool(config.search_query) + config.needs_redraw = True + elif config.search_mode and not config.is_non_pc: + # Gestion de la recherche sur PC + if event.type == pygame.KEYDOWN: + # Saisie de texte alphanumĂ©rique + if event.unicode.isalnum() or event.unicode == ' ': + config.search_query += event.unicode + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug(f"Recherche mise Ă  jour: query={config.search_query}, jeux filtrĂ©s={len(config.filtered_games)}") + # Gestion de la suppression + elif is_input_matched(event, "delete"): + if config.search_query: + config.search_query = config.search_query[:-1] + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug(f"Suppression caractĂšre: query={config.search_query}, jeux filtrĂ©s={len(config.filtered_games)}") + # Gestion de la validation + elif is_input_matched(event, "confirm"): + config.search_mode = False + config.filter_active = True # Conserver le filtre actif + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug(f"Validation de la recherche: query={config.search_query}, jeux filtrĂ©s={len(config.filtered_games)}") + # Gestion de l'annulation + elif is_input_matched(event, "cancel"): + config.search_mode = False + config.search_query = "" + config.filtered_games = config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug("Sortie du mode recherche") + + else: + if is_input_matched(event, "up"): + if config.current_game > 0: + config.current_game -= 1 + update_key_state("up", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + config.needs_redraw = True + elif is_input_matched(event, "down"): + if config.current_game < len(games) - 1: + config.current_game += 1 + update_key_state("down", True, event.type, event.key if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else + event.value) + config.needs_redraw = True + elif is_input_matched(event, "page_up"): + config.current_game = max(0, config.current_game - config.visible_games) + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + elif is_input_matched(event, "page_down"): + config.current_game = min(len(games) - 1, config.current_game + config.visible_games) + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + elif is_input_matched(event, "filter"): + config.search_mode = True + config.search_query = "" + config.filtered_games = config.games + config.current_game = 0 + config.scroll_offset = 0 + config.selected_key = (0, 0) + config.needs_redraw = True + logger.debug("EntrĂ©e en mode recherche") + elif is_input_matched(event, "progress"): + if config.download_tasks: + config.previous_menu_state = config.menu_state + config.menu_state = "download_progress" + config.needs_redraw = True + logger.debug(f"Retour Ă  download_progress depuis {config.previous_menu_state}") + elif is_input_matched(event, "history"): + config.menu_state = "history" + config.needs_redraw = True + logger.debug("Ouverture history depuis game") + elif is_input_matched(event, "cancel"): + config.menu_state = "platform" + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug("Retour Ă  platform") + elif is_input_matched(event, "redownload_game_cache"): + config.previous_menu_state = config.menu_state + config.menu_state = "redownload_game_cache" + config.needs_redraw = True + logger.debug("Passage Ă  redownload_game_cache depuis game") + # SĂ©lectionner un jeu, Ă©vĂ©nement confirm + elif is_input_matched(event, "confirm"): + if games: + url = games[config.current_game][1] + game_name = games[config.current_game][0] + platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform] + logger.debug(f"VĂ©rification pour {game_name}, URL: {url}") + # Ajouter une entrĂ©e temporaire Ă  l'historique + config.history.append(add_to_history( + platform=platform, + game_name=game_name, + status="downloading", + url=url, + progress=0, + message="TĂ©lĂ©chargement en cours" + )) + config.current_history_item = len(config.history) - 1 + # VĂ©rifier d'abord si c'est un lien 1fichier + if is_1fichier_url(url): + if not config.API_KEY_1FICHIER: + config.previous_menu_state = config.menu_state + config.menu_state = "error" + try: + config.error_message = _("error_api_key_extended") + except Exception as e: + logger.error(f"Erreur lors de la traduction de error_api_key_extended: {str(e)}") + config.error_message = "Missing 1fichier API key" # Message de secours + config.history[-1]["status"] = "Erreur" + config.history[-1]["progress"] = 0 + config.history[-1]["message"] = "Erreur API : ClĂ© API 1fichier absente" + save_history(config.history) + config.needs_redraw = True + logger.error("ClĂ© API 1fichier absente, tĂ©lĂ©chargement impossible.") + config.pending_download = None + return action + config.pending_download = check_extension_before_download(url, platform, game_name) + if config.pending_download: + is_supported = is_extension_supported( + sanitize_filename(game_name), + platform, + load_extensions_json() + ) + if not is_supported: + config.previous_menu_state = config.menu_state + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non supportĂ©e, passage Ă  extension_warning pour {game_name}") + config.history.pop() # Supprimer l'entrĂ©e temporaire + else: + task_id = str(pygame.time.get_ticks()) + task = asyncio.create_task(download_from_1fichier(url, platform, game_name, config.pending_download[3], task_id)) + config.download_tasks[task_id] = (task, url, game_name, platform) + config.previous_menu_state = config.menu_state + config.menu_state = "history" # Passer Ă  l'historique + config.needs_redraw = True + logger.debug(f"DĂ©but du tĂ©lĂ©chargement 1fichier: {game_name} pour {platform} depuis {url}, task_id={task_id}") + config.pending_download = None + action = "download" + else: + config.menu_state = "error" + config.error_message = "Extension non supportĂ©e ou erreur de tĂ©lĂ©chargement" + config.pending_download = None + config.needs_redraw = True + logger.error(f"config.pending_download est None pour {game_name}") + config.history.pop() # Supprimer l'entrĂ©e temporaire + else: + config.pending_download = check_extension_before_download(url, platform, game_name) + if config.pending_download: + is_supported = is_extension_supported( + sanitize_filename(game_name), + platform, + load_extensions_json() + ) + if not is_supported: + config.previous_menu_state = config.menu_state + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non supportĂ©e, passage Ă  extension_warning pour {game_name}") + config.history.pop() # Supprimer l'entrĂ©e temporaire + else: + task_id = str(pygame.time.get_ticks()) + task = asyncio.create_task(download_rom(url, platform, game_name, config.pending_download[3], task_id)) + config.download_tasks[task_id] = (task, url, game_name, platform) + config.previous_menu_state = config.menu_state + config.menu_state = "history" # Passer Ă  l'historique + config.needs_redraw = True + logger.debug(f"DĂ©but du tĂ©lĂ©chargement: {game_name} pour {platform} depuis {url}, task_id={task_id}") + config.pending_download = None + action = "download" + else: + config.menu_state = "error" + config.error_message = "Extension non supportĂ©e ou erreur de tĂ©lĂ©chargement" + config.pending_download = None + config.needs_redraw = True + logger.error(f"config.pending_download est None pour {game_name}") + config.history.pop() # Supprimer l'entrĂ©e temporaire + + # Avertissement extension + elif config.menu_state == "extension_warning": + if is_input_matched(event, "confirm"): + if config.extension_confirm_selection == 1: + if config.pending_download and len(config.pending_download) == 4: + url, platform, game_name, is_zip_non_supported = config.pending_download + # Ajouter une entrĂ©e temporaire Ă  l'historique + config.history.append(add_to_history( + platform=platform, + game_name=game_name, + status="downloading", + url=url, + progress=0, + message="TĂ©lĂ©chargement en cours" + )) + config.current_history_item = len(config.history) - 1 + if is_1fichier_url(url): + if not config.API_KEY_1FICHIER: + config.previous_menu_state = config.menu_state + config.menu_state = "error" + config.error_message = _( + "error_api_key" + ).format("/userdata/saves/ports/rgsx/1fichierAPI.txt") + config.history[-1]["status"] = "Erreur" + config.history[-1]["progress"] = 0 + config.history[-1]["message"] = "Erreur API : ClĂ© API 1fichier absente" + save_history(config.history) + config.needs_redraw = True + logger.error("ClĂ© API 1fichier absente, tĂ©lĂ©chargement impossible.") + config.pending_download = None + return action + task_id = str(pygame.time.get_ticks()) + task = asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported, task_id)) + else: + task_id = str(pygame.time.get_ticks()) + task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id)) + config.download_tasks[task_id] = (task, url, game_name, platform) + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "history" # Passer Ă  l'historique + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement confirmĂ© aprĂšs avertissement: {game_name} pour {platform} depuis {url}, task_id={task_id}") + config.pending_download = None + action = "download" + else: + config.menu_state = "error" + config.error_message = _("error_invalid_download_data") + config.pending_download = None + config.needs_redraw = True + logger.error("config.pending_download invalide") + config.history.pop() # Supprimer l'entrĂ©e temporaire + else: + config.pending_download = None + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis extension_warning") + elif is_input_matched(event, "left") or is_input_matched(event, "right"): + config.extension_confirm_selection = 1 - config.extension_confirm_selection + config.needs_redraw = True + elif is_input_matched(event, "cancel"): + config.pending_download = None + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis extension_warning") + + #Historique + elif config.menu_state == "history": + history = config.history + if is_input_matched(event, "up"): + if config.current_history_item > 0: + config.current_history_item -= 1 + config.repeat_action = "up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "down"): + if config.current_history_item < len(history) - 1: + config.current_history_item += 1 + config.repeat_action = "down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "page_up"): + config.current_history_item = max(0, config.current_history_item - config.visible_history_items) + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + #logger.debug("Page prĂ©cĂ©dente dans l'historique") + elif is_input_matched(event, "page_down"): + config.current_history_item = min(len(history) - 1, config.current_history_item + config.visible_history_items) + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + #logger.debug("Page suivante dans l'historique") + elif is_input_matched(event, "progress"): + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "confirm_clear_history" + config.confirm_clear_selection = 0 # 0 pour "Non", 1 pour "Oui" + config.needs_redraw = True + logger.debug("Passage Ă  confirm_clear_history depuis history") + elif is_input_matched(event, "confirm"): + if history: + entry = history[config.current_history_item] + platform = entry["platform"] + game_name = entry["game_name"] + for game in config.games: + if game[0] == game_name and config.platforms[config.current_platform] == platform: + config.pending_download = check_extension_before_download(game[1], platform, game_name) + if config.pending_download: + url, platform, game_name, is_zip_non_supported = config.pending_download + if is_zip_non_supported: + config.previous_menu_state = config.menu_state + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non supportĂ©e pour retĂ©lĂ©chargement, passage Ă  extension_warning pour {game_name}") + else: + task_id = str(pygame.time.get_ticks()) + if is_1fichier_url(url): + if not config.API_KEY_1FICHIER: + config.previous_menu_state = config.menu_state + config.menu_state = "error" + config.error_message = ( + "Attention il faut renseigner sa clĂ© API (premium only) dans le fichier /userdata/saves/ports/rgsx/1fichierAPI.txt" + ) + config.history[-1]["status"] = "Erreur" + config.history[-1]["progress"] = 0 + config.history[-1]["message"] = "Erreur API : ClĂ© API 1fichier absente" + save_history(config.history) + config.needs_redraw = True + logger.error("ClĂ© API 1fichier absente, retĂ©lĂ©chargement impossible.") + config.pending_download = None + return action + task = asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported, task_id)) + else: + task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id)) + config.download_tasks[task_id] = (task, url, game_name, platform) + config.previous_menu_state = config.menu_state + config.menu_state = "history" + config.needs_redraw = True + logger.debug(f"RetĂ©lĂ©chargement: {game_name} pour {platform} depuis {url}, task_id={task_id}") + config.pending_download = None + action = "redownload" + else: + config.menu_state = "error" + config.error_message = "Extension non supportĂ©e ou erreur de retĂ©lĂ©chargement" + config.pending_download = None + config.needs_redraw = True + logger.error(f"config.pending_download est None pour {game_name}") + break + elif is_input_matched(event, "cancel"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.current_history_item = 0 + config.history_scroll_offset = 0 + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis history") + + # Confirmation vider l'historique" + elif config.menu_state == "confirm_clear_history": + logger.debug(f"État confirm_clear_history, confirm_clear_selection={config.confirm_clear_selection}, Ă©vĂ©nement={event.type}, valeur={getattr(event, 'value', None)}") + if is_input_matched(event, "confirm"): + logger.debug(f"Action confirm dĂ©tectĂ©e dans confirm_clear_history") + if config.confirm_clear_selection == 1: # Oui + clear_history() + config.history = [] + config.current_history_item = 0 + config.history_scroll_offset = 0 + config.menu_state = "history" + config.needs_redraw = True + logger.info("Historique vidĂ© aprĂšs confirmation") + else: # Non + config.menu_state = "history" + config.needs_redraw = True + logger.debug("Annulation du vidage de l'historique, retour Ă  history") + elif is_input_matched(event, "left"): + #logger.debug(f"Action left dĂ©tectĂ©e dans confirm_clear_history") + config.confirm_clear_selection = 1 # SĂ©lectionner "Non" + config.needs_redraw = True + #logger.debug(f"Changement sĂ©lection confirm_clear_history: {config.confirm_clear_selection}") + elif is_input_matched(event, "right"): + #logger.debug(f"Action right dĂ©tectĂ©e dans confirm_clear_history") + config.confirm_clear_selection = 0 # SĂ©lectionner "Oui" + config.needs_redraw = True + #logger.debug(f"Changement sĂ©lection confirm_clear_history: {config.confirm_clear_selection}") + elif is_input_matched(event, "cancel"): + #logger.debug(f"Action cancel dĂ©tectĂ©e dans confirm_clear_history") + config.menu_state = "history" + config.needs_redraw = True + logger.debug("Annulation du vidage de l'historique, retour Ă  history") + + # Progression tĂ©lĂ©chargement + elif config.menu_state == "download_progress": + if is_input_matched(event, "cancel"): + for task in config.download_tasks: + task.cancel() + config.download_tasks.clear() + config.download_progress.clear() + config.pending_download = None + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement annulĂ©, retour Ă  {config.menu_state}") + elif is_input_matched(event, "progress"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis download_progress") + + # RĂ©sultat tĂ©lĂ©chargement + elif config.menu_state == "download_result": + if is_input_matched(event, "confirm"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.popup_timer = 0 + config.pending_download = None + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis download_result") + + # Confirmation quitter + elif config.menu_state == "confirm_exit": + if is_input_matched(event, "confirm"): + if config.confirm_selection == 1: + return "quit" + else: + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis confirm_exit") + elif is_input_matched(event, "left") or is_input_matched(event, "right"): + config.confirm_selection = 1 - config.confirm_selection + config.needs_redraw = True + #logger.debug(f"Changement sĂ©lection confirm_exit: {config.confirm_selection}") + + # Menu pause + elif config.menu_state == "pause_menu": + logger.debug(f"État pause_menu, selected_option={config.selected_option}, Ă©vĂ©nement={event.type}, valeur={getattr(event, 'value', None)}") + if is_input_matched(event, "up"): + config.selected_option = max(0, config.selected_option - 1) + # La rĂ©pĂ©tition est gĂ©rĂ©e par update_key_state + config.needs_redraw = True + logger.debug(f"Navigation vers le haut: selected_option={config.selected_option}") + elif is_input_matched(event, "down"): + config.selected_option = min(5, config.selected_option + 1) + # La rĂ©pĂ©tition est gĂ©rĂ©e par update_key_state + config.needs_redraw = True + logger.debug(f"Navigation vers le bas: selected_option={config.selected_option}") + elif is_input_matched(event, "confirm"): + logger.debug(f"Confirmation dans pause_menu avec selected_option={config.selected_option}") + if config.selected_option == 0: # Controls + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "controls_help" + config.needs_redraw = True + logger.debug(f"Passage Ă  controls_help depuis pause_menu") + elif config.selected_option == 1: # Remap controls + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + logger.debug(f"Previous menu state avant controls_mapping: {config.previous_menu_state}") + config.menu_state = "controls_mapping" + config.needs_redraw = True + logger.debug(f"Passage Ă  controls_mapping depuis pause_menu") + elif config.selected_option == 2: # History + config.history = load_history() + config.current_history_item = 0 + config.history_scroll_offset = 0 + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "history" + config.needs_redraw = True + logger.debug(f"Passage Ă  history depuis pause_menu") + elif config.selected_option == 3: # Language + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "language_select" + config.selected_language_index = 0 + config.needs_redraw = True + logger.debug(f"Passage Ă  language_select depuis pause_menu") + elif config.selected_option == 4: # Redownload game cache + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "redownload_game_cache" + config.redownload_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Passage Ă  redownload_game_cache depuis pause_menu") + elif config.selected_option == 5: # Quit + config.previous_menu_state = validate_menu_state(config.previous_menu_state) + config.menu_state = "confirm_exit" + config.confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Passage Ă  confirm_exit depuis pause_menu") + elif is_input_matched(event, "cancel"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis pause_menu") + + # Aide contrĂŽles + elif config.menu_state == "controls_help": + if is_input_matched(event, "cancel"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis controls_help") + + # Remap controls + elif config.menu_state == "controls_mapping": + if is_input_matched(event, "cancel"): + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug("Retour Ă  pause_menu depuis controls_mapping") + + # Redownload game cache + elif config.menu_state == "redownload_game_cache": + if is_input_matched(event, "left") or is_input_matched(event, "right"): + config.redownload_confirm_selection = 1 - config.redownload_confirm_selection + config.needs_redraw = True + logger.debug(f"Changement sĂ©lection redownload_game_cache: {config.redownload_confirm_selection}") + elif is_input_matched(event, "confirm"): + logger.debug(f"Action confirm dans redownload_game_cache, sĂ©lection={config.redownload_confirm_selection}") + if config.redownload_confirm_selection == 1: # Oui + logger.debug("DĂ©but du redownload des jeux") + config.download_tasks.clear() + config.download_progress.clear() + config.pending_download = None + if os.path.exists(config.APP_FOLDER + "/sources.json"): + try: + os.remove(config.APP_FOLDER + "/sources.json") + logger.debug("Fichier sources.json supprimĂ© avec succĂšs") + if os.path.exists(config.APP_FOLDER + "/games"): + shutil.rmtree(config.APP_FOLDER + "/games") + logger.debug("Dossier games supprimĂ© avec succĂšs") + if os.path.exists(config.APP_FOLDER + "/images"): + shutil.rmtree(config.APP_FOLDER + "/images") + logger.debug("Dossier images supprimĂ© avec succĂšs") + config.menu_state = "restart_popup" + config.popup_message = _("popup_redownload_success") + config.popup_timer = 5000 # 5 secondes + config.needs_redraw = True + logger.debug("Passage Ă  restart_popup") + except Exception as e: + logger.error(f"Erreur lors de la suppression du fichier sources.json ou dossiers: {e}") + config.menu_state = "error" + config.error_message = _("error_delete_sources") + config.needs_redraw = True + return action + else: + logger.debug("Fichier sources.json non trouvĂ©, passage Ă  restart_popup") + config.menu_state = "restart_popup" + config.popup_message = _("popup_no_cache") + config.popup_timer = 5000 # 5 secondes + config.needs_redraw = True + logger.debug("Passage Ă  restart_popup") + else: # Non + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Annulation du redownload, retour Ă  {config.menu_state}") + elif is_input_matched(event, "cancel"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.needs_redraw = True + logger.debug(f"Retour Ă  {config.menu_state} depuis redownload_game_cache") + + + # Popup de redĂ©marrage + elif config.menu_state == "restart_popup": + if is_input_matched(event, "confirm") or is_input_matched(event, "cancel"): + config.menu_state = validate_menu_state(config.previous_menu_state) + config.popup_message = "" + config.popup_timer = 0 + config.needs_redraw = True + logger.debug(f"Retour manuel Ă  {config.menu_state} depuis restart_popup") + + # SĂ©lecteur de langue + elif config.menu_state == "language_select": + # Gestion directe des Ă©vĂ©nements pour le sĂ©lecteur de langue + from language import get_available_languages, set_language, _ + + available_languages = get_available_languages() + + if not available_languages: + logger.error("Aucune langue disponible") + config.menu_state = "pause_menu" + config.needs_redraw = True + return action + + # Navigation directe avec les touches du clavier + if event.type == pygame.KEYDOWN: + # Navigation vers le haut + if event.key == pygame.K_UP: + config.selected_language_index = (config.selected_language_index - 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le haut dans le sĂ©lecteur de langue: {config.selected_language_index}") + + # Navigation vers le bas + elif event.key == pygame.K_DOWN: + config.selected_language_index = (config.selected_language_index + 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le bas dans le sĂ©lecteur de langue: {config.selected_language_index}") + + # SĂ©lection de la langue + elif event.key == pygame.K_RETURN: + lang_code = available_languages[config.selected_language_index] + if set_language(lang_code): + logger.info(f"Langue changĂ©e pour {lang_code}") + config.current_language = lang_code + # Afficher un message de confirmation + config.menu_state = "restart_popup" + config.popup_message = _("language_changed").format(lang_code) + config.popup_timer = 2000 # 2 secondes + else: + # Retour au menu pause en cas d'erreur + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug(f"SĂ©lection de la langue: {lang_code}") + + # Annulation + elif event.key == pygame.K_ESCAPE: + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug("Annulation de la sĂ©lection de langue, retour au menu pause") + + + # Gestion des relĂąchements de touches + if event.type == pygame.KEYUP: + # VĂ©rifier quelle touche a Ă©tĂ© relĂąchĂ©e + for action_name in ["up", "down", "left", "right", "confirm", "cancel"]: + if config.controls_config.get(action_name, {}).get("type") == "key" and \ + config.controls_config.get(action_name, {}).get("value") == event.key: + update_key_state(action_name, False) + + elif event.type == pygame.JOYBUTTONUP: + # VĂ©rifier quel bouton a Ă©tĂ© relĂąchĂ© + for action_name in ["up", "down", "left", "right", "confirm", "cancel"]: + if config.controls_config.get(action_name, {}).get("type") == "button" and \ + config.controls_config.get(action_name, {}).get("value") == event.button: + update_key_state(action_name, False) + + elif event.type == pygame.JOYAXISMOTION and abs(event.value) < 0.5: + # VĂ©rifier quel axe a Ă©tĂ© relĂąchĂ© + for action_name in ["up", "down", "left", "right"]: + if config.controls_config.get(action_name, {}).get("type") == "axis" and \ + config.controls_config.get(action_name, {}).get("value")[0] == event.axis: + update_key_state(action_name, False) + + return action + +# Nouvelle implĂ©mentation de la rĂ©pĂ©tition des touches +def update_key_state(action, pressed, event_type=None, event_value=None): + """Met Ă  jour l'Ă©tat d'une touche pour la rĂ©pĂ©tition automatique.""" + current_time = pygame.time.get_ticks() + + if pressed: + # La touche vient d'ĂȘtre pressĂ©e + if action not in key_states: + # Ajouter un dĂ©lai initial pour Ă©viter les doubles actions sur appui court + initial_debounce = REPEAT_ACTION_DEBOUNCE + key_states[action] = { + "pressed": True, + "first_press_time": current_time + initial_debounce, # Ajouter un dĂ©lai initial + "last_repeat_time": current_time, + "event_type": event_type, + "event_value": event_value + } + else: + # La touche vient d'ĂȘtre relĂąchĂ©e + if action in key_states: + del key_states[action] + +def process_key_repeats(sources, joystick, screen): + """Traite la rĂ©pĂ©tition des touches.""" + current_time = pygame.time.get_ticks() + + for action, state in list(key_states.items()): + if not state["pressed"]: + continue + + time_since_first_press = current_time - state["first_press_time"] + time_since_last_repeat = current_time - state["last_repeat_time"] + + # VĂ©rifier si nous devons dĂ©clencher une rĂ©pĂ©tition + if (time_since_first_press > REPEAT_DELAY and + time_since_last_repeat > REPEAT_INTERVAL): + + # CrĂ©er un Ă©vĂ©nement synthĂ©tique selon le type + event_type = state["event_type"] + event_value = state["event_value"] + + if event_type == pygame.KEYDOWN: + event = pygame.event.Event(pygame.KEYDOWN, {"key": event_value}) + elif event_type == pygame.JOYBUTTONDOWN: + event = pygame.event.Event(pygame.JOYBUTTONDOWN, {"button": event_value}) + elif event_type == pygame.JOYAXISMOTION: + axis, value = event_value + event = pygame.event.Event(pygame.JOYAXISMOTION, {"axis": axis, "value": value}) + elif event_type == pygame.JOYHATMOTION: + event = pygame.event.Event(pygame.JOYHATMOTION, {"value": event_value}) + else: + continue # Type d'Ă©vĂ©nement non pris en charge + + # Traiter l'Ă©vĂ©nement rĂ©pĂ©tĂ© + handle_controls(event, sources, joystick, screen) + + # Mettre Ă  jour le temps de la derniĂšre rĂ©pĂ©tition + state["last_repeat_time"] = current_time + + # Forcer le redessinage + config.needs_redraw = True \ No newline at end of file diff --git a/controls_mapper.py b/controls_mapper.py new file mode 100644 index 0000000..9649d6a --- /dev/null +++ b/controls_mapper.py @@ -0,0 +1,516 @@ +import pygame # type: ignore +import json +import os +import logging +import config +from config import CONTROLS_CONFIG_PATH +from display import draw_gradient + +logger = logging.getLogger(__name__) + +# Chemin du fichier de configuration des contrĂŽles +CONTROLS_CONFIG_PATH = "/userdata/saves/ports/rgsx/controls.json" + +# Actions internes de RGSX Ă  mapper +ACTIONS = [ + {"name": "confirm", "display": "Confirmer", "description": "Valider (RecommandĂ©: EntrĂ©e, A/Croix)"}, + {"name": "cancel", "display": "Annuler", "description": "Annuler/Retour (RecommandĂ©: Retour ArriĂšre, B/Rond)"}, + {"name": "up", "display": "Haut", "description": "Naviguer vers le haut"}, + {"name": "down", "display": "Bas", "description": "Naviguer vers le bas"}, + {"name": "left", "display": "Gauche", "description": "Naviguer Ă  gauche"}, + {"name": "right", "display": "Droite", "description": "Naviguer Ă  droite"}, + {"name": "page_up", "display": "Page PrĂ©cĂ©dente", "description": "Page prĂ©cĂ©dente/DĂ©filement Rapide Haut (RecommandĂ©: PageUp, LB/L1)"}, + {"name": "page_down", "display": "Page Suivante", "description": "Page suivante/DĂ©filement Rapide Bas (RecommandĂ©: PageDown, RB/R1)"}, + {"name": "history", "display": "Historique", "description": "Ouvrir l'historique (RecommandĂ©: H, Y/Triangle)"}, + {"name": "progress", "display": "Progression", "description": "Historique : Effacer la liste (RecommandĂ©: X/CarrĂ©)"}, + {"name": "filter", "display": "Filtrer", "description": "Ouvrir filtre (RecommandĂ©: F, Select)"}, + {"name": "delete", "display": "Supprimer", "description": "Mode Fitre : Supprimer caractĂšre en mode recherche (RecommandĂ©: DEL, LT/L2)"}, + {"name": "space", "display": "Espace", "description": "Mode Filtre : Ajouter espace (RecommandĂ©: Espace, RT/R2)"}, + {"name": "start", "display": "Start", "description": "Menu pause / ParamĂštres (RecommandĂ©: Start, AltGr)"}, +] + +# Mappage des valeurs SDL vers les constantes Pygame +SDL_TO_PYGAME_KEY = { + 1073741906: pygame.K_UP, # FlĂšche Haut + 1073741905: pygame.K_DOWN, # FlĂšche Bas + 1073741904: pygame.K_LEFT, # FlĂšche Gauche + 1073741903: pygame.K_RIGHT, # FlĂšche Droite + 1073742050: pygame.K_LALT, # Alt gauche + 1073742051: pygame.K_RSHIFT, # Alt droit + 1073742049: pygame.K_LCTRL, # Ctrl gauche + 1073742053: pygame.K_RCTRL, # Ctrl droit + 1073742048: pygame.K_LSHIFT, # Shift gauche + 1073742054: pygame.K_RALT, # Shift droit +} + +# Noms lisibles pour les touches clavier +KEY_NAMES = { + pygame.K_RETURN: "EntrĂ©e", + pygame.K_ESCAPE: "Échap", + pygame.K_SPACE: "Espace", + pygame.K_UP: "FlĂšche Haut", + pygame.K_DOWN: "FlĂšche Bas", + pygame.K_LEFT: "FlĂšche Gauche", + pygame.K_RIGHT: "FlĂšche Droite", + pygame.K_BACKSPACE: "Retour ArriĂšre", + pygame.K_TAB: "Tab", + pygame.K_LALT: "Alt", + pygame.K_RALT: "AltGR", + pygame.K_LCTRL: "LCtrl", + pygame.K_RCTRL: "RCtrl", + pygame.K_LSHIFT: "LShift", + pygame.K_RSHIFT: "RShift", + pygame.K_LMETA: "LMeta", + pygame.K_RMETA: "RMeta", + pygame.K_CAPSLOCK: "Verr Maj", + pygame.K_NUMLOCK: "Verr Num", + pygame.K_SCROLLOCK: "Verr DĂ©f", + pygame.K_a: "A", + pygame.K_b: "B", + pygame.K_c: "C", + pygame.K_d: "D", + pygame.K_e: "E", + pygame.K_f: "F", + pygame.K_g: "G", + pygame.K_h: "H", + pygame.K_i: "I", + pygame.K_j: "J", + pygame.K_k: "K", + pygame.K_l: "L", + pygame.K_m: "M", + pygame.K_n: "N", + pygame.K_o: "O", + pygame.K_p: "P", + pygame.K_q: "Q", + pygame.K_r: "R", + pygame.K_s: "S", + pygame.K_t: "T", + pygame.K_u: "U", + pygame.K_v: "V", + pygame.K_w: "W", + pygame.K_x: "X", + pygame.K_y: "Y", + pygame.K_z: "Z", + pygame.K_0: "0", + pygame.K_1: "1", + pygame.K_2: "2", + pygame.K_3: "3", + pygame.K_4: "4", + pygame.K_5: "5", + pygame.K_6: "6", + pygame.K_7: "7", + pygame.K_8: "8", + pygame.K_9: "9", + pygame.K_KP0: "PavĂ© 0", + pygame.K_KP1: "PavĂ© 1", + pygame.K_KP2: "PavĂ© 2", + pygame.K_KP3: "PavĂ© 3", + pygame.K_KP4: "PavĂ© 4", + pygame.K_KP5: "PavĂ© 5", + pygame.K_KP6: "PavĂ© 6", + pygame.K_KP7: "PavĂ© 7", + pygame.K_KP8: "PavĂ© 8", + pygame.K_KP9: "PavĂ© 9", + pygame.K_KP_PERIOD: "PavĂ© .", + pygame.K_KP_DIVIDE: "PavĂ© /", + pygame.K_KP_MULTIPLY: "PavĂ© *", + pygame.K_KP_MINUS: "PavĂ© -", + pygame.K_KP_PLUS: "PavĂ© +", + pygame.K_KP_ENTER: "PavĂ© EntrĂ©e", + pygame.K_KP_EQUALS: "PavĂ© =", + pygame.K_F1: "F1", + pygame.K_F2: "F2", + pygame.K_F3: "F3", + pygame.K_F4: "F4", + pygame.K_F5: "F5", + pygame.K_F6: "F6", + pygame.K_F7: "F7", + pygame.K_F8: "F8", + pygame.K_F9: "F9", + pygame.K_F10: "F10", + pygame.K_F11: "F11", + pygame.K_F12: "F12", + pygame.K_F13: "F13", + pygame.K_F14: "F14", + pygame.K_F15: "F15", + pygame.K_INSERT: "Inser", + pygame.K_DELETE: "Suppr", + pygame.K_HOME: "DĂ©but", + pygame.K_END: "Fin", + pygame.K_PAGEUP: "Page Haut", + pygame.K_PAGEDOWN: "Page Bas", + pygame.K_PRINT: "Impr Écran", + pygame.K_SYSREQ: "SysReq", + pygame.K_BREAK: "Pause", + pygame.K_PAUSE: "Pause", + pygame.K_BACKQUOTE: "`", + pygame.K_MINUS: "-", + pygame.K_EQUALS: "=", + pygame.K_LEFTBRACKET: "[", + pygame.K_RIGHTBRACKET: "]", + pygame.K_BACKSLASH: "\\", + pygame.K_SEMICOLON: ";", + pygame.K_QUOTE: "'", + pygame.K_COMMA: ",", + pygame.K_PERIOD: ".", + pygame.K_SLASH: "/", +} + +# Noms lisibles pour les boutons de manette +BUTTON_NAMES = { + 0: "A", + 1: "B", + 2: "X", + 3: "Y", + 4: "LB", + 5: "RB", + 6: "LT", + 7: "RT", + 8: "Select", + 9: "Start", +} + +# Noms pour les axes de joystick +AXIS_NAMES = { + (0, 1): "Joy G Haut", + (0, -1): "Joy G Bas", + (1, 1): "Joy G Gauche", + (1, -1): "Joy G Droite", + (2, 1): "Joy D Haut", + (2, -1): "Joy D Bas", + (3, 1): "Joy D Gauche", + (3, -1): "Joy D Droite", +} + +# Noms pour la croix directionnelle +HAT_NAMES = { + (0, 1): "D-Pad Haut", + (0, -1): "D-Pad Bas", + (-1, 0): "D-Pad Gauche", + (1, 0): "D-Pad Droite", +} + +# Noms pour les boutons de souris +MOUSE_BUTTON_NAMES = { + 1: "Clic Gauche", + 2: "Clic Milieu", + 3: "Clic Droit", +} + +# DurĂ©e de maintien pour valider une entrĂ©e (en millisecondes) +HOLD_DURATION = 1000 + +JOYHAT_DEBOUNCE = 200 # DĂ©lai anti-rebond pour JOYHATMOTION (ms) + +def load_controls_config(): + #Charge la configuration des contrĂŽles depuis controls.json + try: + if os.path.exists(CONTROLS_CONFIG_PATH): + with open(CONTROLS_CONFIG_PATH, "r") as f: + config = json.load(f) + logger.debug(f"Configuration des contrĂŽles chargĂ©e : {config}") + return config + else: + logger.debug("Aucun fichier controls.json trouvĂ©, configuration par dĂ©faut.") + return {} + except Exception as e: + logger.error(f"Erreur lors du chargement de controls.json : {e}") + return {} + +def save_controls_config(controls_config): + #Enregistre la configuration des contrĂŽles dans controls.json + try: + os.makedirs(os.path.dirname(CONTROLS_CONFIG_PATH), exist_ok=True) + with open(CONTROLS_CONFIG_PATH, "w") as f: + json.dump(controls_config, f, indent=4) + logger.debug(f"Configuration des contrĂŽles enregistrĂ©e : {controls_config}") + except Exception as e: + logger.error(f"Erreur lors de l'enregistrement de controls.json : {e}") + +def get_readable_input_name(event): + #Retourne un nom lisible pour une entrĂ©e (touche, bouton, axe, hat, ou souris) + if event.type == pygame.KEYDOWN: + key_value = SDL_TO_PYGAME_KEY.get(event.key, event.key) + return KEY_NAMES.get(key_value, pygame.key.name(key_value) or f"Touche {key_value}") + elif event.type == pygame.JOYBUTTONDOWN: + return BUTTON_NAMES.get(event.button, f"Bouton {event.button}") + elif event.type == pygame.JOYAXISMOTION: + if abs(event.value) > 0.5: # Seuil pour dĂ©tecter un mouvement significatif + return AXIS_NAMES.get((event.axis, 1 if event.value > 0 else -1), f"Axe {event.axis}") + elif event.type == pygame.JOYHATMOTION: + return HAT_NAMES.get(event.value, f"D-Pad {event.value}") + elif event.type == pygame.MOUSEBUTTONDOWN: + return MOUSE_BUTTON_NAMES.get(event.button, f"Souris Bouton {event.button}") + return "Inconnu" + + +def map_controls(screen): + mapping = True + current_action = 0 + clock = pygame.time.Clock() + while mapping: + clock.tick(100) # 100 FPS + for event in pygame.event.get(): + # Initialisation des variables de contrĂŽle + controls_config = load_controls_config() + current_action_index = 0 + current_input = None + input_held_time = 0 + last_input_name = None + last_frame_time = pygame.time.get_ticks() + config.needs_redraw = True + last_joyhat_time = 0 # Pour le dĂ©bouncing des Ă©vĂ©nements JOYHATMOTION + + # Initialiser l'Ă©tat des boutons et axes pour suivre les relĂąchements + held_keys = set() + held_buttons = set() + held_axes = {} # {axis: direction} + held_hats = {} # {hat: value} + held_mouse_buttons = set() + + while current_action_index < len(ACTIONS): + if config.needs_redraw: + progress = min(input_held_time / HOLD_DURATION, 1.0) if current_input else 0.0 + draw_controls_mapping(screen, ACTIONS[current_action_index], last_input_name, current_input is not None, progress) + pygame.display.flip() + config.needs_redraw = False + + current_time = pygame.time.get_ticks() + delta_time = current_time - last_frame_time + last_frame_time = current_time + + events = pygame.event.get() + for event in events: + if event.type == pygame.QUIT: + return False + + # DĂ©tecter les relĂąchements pour rĂ©initialiser + if event.type == pygame.KEYUP: + if event.key in held_keys: + held_keys.remove(event.key) + if current_input and current_input["type"] == "key" and current_input["value"] == event.key: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Touche relĂąchĂ©e: {event.key}") + elif event.type == pygame.JOYBUTTONUP: + if event.button in held_buttons: + held_buttons.remove(event.button) + if current_input and current_input["type"] == "button" and current_input["value"] == event.button: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Bouton relĂąchĂ©: {event.button}") + elif event.type == pygame.MOUSEBUTTONUP: + if event.button in held_mouse_buttons: + held_mouse_buttons.remove(event.button) + if current_input and current_input["type"] == "mouse" and current_input["value"] == event.button: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Bouton souris relĂąchĂ©: {event.button}") + elif event.type == pygame.JOYAXISMOTION: + if abs(event.value) < 0.5: # Axe revenu Ă  la position neutre + if event.axis in held_axes: + del held_axes[event.axis] + if current_input and current_input["type"] == "axis" and current_input["value"][0] == event.axis: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Axe relĂąchĂ©: {event.axis}") + elif event.type == pygame.JOYHATMOTION: + logger.debug(f"JOYHATMOTION dĂ©tectĂ©: hat={event.hat}, value={event.value}") + if event.value == (0, 0): # D-Pad revenu Ă  la position neutre + if event.hat in held_hats: + del held_hats[event.hat] + if current_input and current_input["type"] == "hat" and current_input["value"] == event.value: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"D-Pad relĂąchĂ©: {event.hat}") + continue # Ignorer les Ă©vĂ©nements (0, 0) pour la dĂ©tection des nouvelles entrĂ©es + + # DĂ©tecter les nouvelles entrĂ©es + if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN, pygame.JOYAXISMOTION, pygame.JOYHATMOTION, pygame.MOUSEBUTTONDOWN): + # Appliquer le dĂ©bouncing pour JOYHATMOTION + if event.type == pygame.JOYHATMOTION and (current_time - last_joyhat_time) < JOYHAT_DEBOUNCE: + logger.debug(f"ÉvĂ©nement JOYHATMOTION ignorĂ© (debounce): hat={event.hat}, value={event.value}") + continue + if event.type == pygame.JOYHATMOTION: + last_joyhat_time = current_time + + + input_name = get_readable_input_name(event) + if input_name != "Inconnu": + input_type = { + pygame.KEYDOWN: "key", + pygame.JOYBUTTONDOWN: "button", + pygame.JOYAXISMOTION: "axis", + pygame.JOYHATMOTION: "hat", + pygame.MOUSEBUTTONDOWN: "mouse", + }[event.type] + input_value = ( + SDL_TO_PYGAME_KEY.get(event.key, event.key) if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION and abs(event.value) > 0.5 else + event.value if event.type == pygame.JOYHATMOTION else + event.button + ) + + # VĂ©rifier si l'entrĂ©e est nouvelle ou diffĂ©rente + if (current_input is None or + (input_type == "key" and current_input["value"] != input_value) or + (input_type == "button" and current_input["value"] != input_value) or + (input_type == "axis" and current_input["value"] != input_value) or + (input_type == "hat" and current_input["value"] != input_value) or + (input_type == "mouse" and current_input["value"] != input_value)): + current_input = {"type": input_type, "value": input_value} + input_held_time = 0 + last_input_name = input_name + config.needs_redraw = True + logger.debug(f"Nouvelle entrĂ©e dĂ©tectĂ©e: {input_type}:{input_value} ({input_name})") + + # Mettre Ă  jour les entrĂ©es maintenues + if input_type == "key": + held_keys.add(input_value) + elif input_type == "button": + held_buttons.add(input_value) + elif input_type == "axis": + held_axes[input_value[0]] = input_value[1] + elif input_type == "hat": + held_hats[event.hat] = input_value + elif input_type == "mouse": + held_mouse_buttons.add(input_value) + + # DĂ©sactivation du passage avec Échap + # Aucun code ici pour empĂȘcher de sauter les actions avec Échap + + # Mettre Ă  jour le temps de maintien + if current_input: + input_held_time += delta_time + if input_held_time >= HOLD_DURATION: + action_name = ACTIONS[current_action_index]["name"] + logger.debug(f"EntrĂ©e validĂ©e pour {action_name}: {current_input['type']}:{current_input['value']} ({last_input_name})") + controls_config[action_name] = { + "type": current_input["type"], + "value": current_input["value"], + "display": last_input_name + } + current_action_index += 1 + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + # RĂ©initialiser les entrĂ©es maintenues pour Ă©viter les interfĂ©rences + held_keys.clear() + held_buttons.clear() + held_axes.clear() + held_hats.clear() + held_mouse_buttons.clear() + config.needs_redraw = True + + pygame.time.wait(10) + + save_controls_config(controls_config) + config.controls_config = controls_config + return True + pass + +def save_controls_config(config): + #Enregistre la configuration des contrĂŽles dans un fichier JSON + try: + with open(CONTROLS_CONFIG_PATH, "w") as f: + json.dump(config, f, indent=4) + logger.debug("Configuration des contrĂŽles enregistrĂ©e") + except Exception as e: + logger.error(f"Erreur lors de l'enregistrement de controls.json : {e}") + return False + return True + +def draw_controls_mapping(screen, action, last_input, waiting_for_input, hold_progress): + #Affiche l'interface de mappage des contrĂŽles avec une barre de progression pour le maintien + draw_gradient(screen, (28, 37, 38), (47, 59, 61)) + + # ParamĂštres de l'interface + padding_horizontal = 40 + padding_vertical = 30 + padding_between = 15 + border_radius = 24 + border_width = 4 + shadow_offset = 8 + + # Titre principal + title_text = "Configuration des contrĂŽles" + title_surface = config.title_font.render(title_text, True, (255, 255, 255)) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, 80)) + screen.blit(title_surface, title_rect) + + # Instructions + instruction_text = "Maintenez pendant 3s pour configurer :" + description_text = action['description'] + instruction_surface = config.small_font.render(instruction_text, True, (255, 255, 255)) + description_surface = config.font.render(description_text, True, (200, 200, 200)) + instruction_width, instruction_height = instruction_surface.get_size() + description_width, description_height = description_surface.get_size() + + # Input dĂ©tectĂ© + input_text = last_input or (f"En attente d'une touche ou bouton..." if waiting_for_input else "Appuyez sur une touche ou un bouton") + input_surface = config.small_font.render(input_text, True, (0, 255, 0) if last_input else (255, 255, 255)) + input_width, input_height = input_surface.get_size() + + # Dimensions de la popup + text_width = max(instruction_width, description_width, input_width) + text_height = instruction_height + description_height + input_height + 2 * padding_between + popup_width = text_width + 2 * padding_horizontal + popup_height = text_height + 40 + 2 * padding_vertical # +40 pour la barre de progression + popup_x = (config.screen_width - popup_width) // 2 + popup_y = (config.screen_height - popup_height) // 2 + + # Ombre portĂ©e + shadow_rect = pygame.Rect(popup_x + shadow_offset, popup_y + shadow_offset, popup_width, popup_height) + shadow_surface = pygame.Surface((popup_width, popup_height), pygame.SRCALPHA) + pygame.draw.rect(shadow_surface, (0, 0, 0, 100), shadow_surface.get_rect(), border_radius=border_radius) + screen.blit(shadow_surface, shadow_rect.topleft) + + # Fond semi-transparent + popup_rect = pygame.Rect(popup_x, popup_y, popup_width, popup_height) + popup_surface = pygame.Surface((popup_width, popup_height), pygame.SRCALPHA) + pygame.draw.rect(popup_surface, (30, 30, 30, 220), popup_surface.get_rect(), border_radius=border_radius) + screen.blit(popup_surface, popup_rect.topleft) + + # Bordure blanche + pygame.draw.rect(screen, (255, 255, 255), popup_rect, border_width, border_radius=border_radius) + + # Afficher les textes + start_y = popup_y + padding_vertical + instruction_rect = instruction_surface.get_rect(center=(config.screen_width // 2, start_y + instruction_height // 2)) + screen.blit(instruction_surface, instruction_rect) + start_y += instruction_height + padding_between + description_rect = description_surface.get_rect(center=(config.screen_width // 2, start_y + description_height // 2)) + screen.blit(description_surface, description_rect) + start_y += description_height + padding_between + input_rect = input_surface.get_rect(center=(config.screen_width // 2, start_y + input_height // 2)) + screen.blit(input_surface, input_rect) + start_y += input_height + padding_between + + # Barre de progression pour le maintien + bar_width = 300 + bar_height = 25 + bar_x = (config.screen_width - bar_width) // 2 + bar_y = start_y + 20 + pygame.draw.rect(screen, (50, 50, 50), (bar_x, bar_y, bar_width, bar_height)) + progress_width = bar_width * hold_progress + pygame.draw.rect(screen, (0, 255, 0), (bar_x, bar_y, progress_width, bar_height)) + pygame.draw.rect(screen, (255, 255, 255), (bar_x, bar_y, bar_width, bar_height), 2) + + # Afficher le pourcentage de progression + if hold_progress > 0: + progress_text = f"{int(hold_progress * 100)}%" + progress_surface = config.small_font.render(progress_text, True, (255, 255, 255)) + progress_rect = progress_surface.get_rect(center=(config.screen_width // 2, bar_y + bar_height + 30)) + screen.blit(progress_surface, progress_rect) \ No newline at end of file diff --git a/display.py b/display.py new file mode 100644 index 0000000..62a93aa --- /dev/null +++ b/display.py @@ -0,0 +1,1244 @@ +import pygame # type: ignore +import config +from utils import truncate_text_middle, wrap_text, load_system_image, truncate_text_end +import logging +import math +from history import load_history # Ajout de l'import +from language import _ # Import de la fonction de traduction + +logger = logging.getLogger(__name__) + +OVERLAY = None # InitialisĂ© dans init_display() + +# Couleurs modernes pour le thĂšme +THEME_COLORS = { + # Fond des lignes sĂ©lectionnĂ©es + "fond_lignes": (0, 255, 0), # vert + # Fond par dĂ©faut des images de grille des systĂšmes + "fond_image": (50, 50, 70), # Bleu sombre mĂ©tal + # NĂ©on image grille des systĂšmes + "neon": (0, 134, 179), # bleu + # DĂ©gradĂ© sombre pour le fond + "background_top": (30, 40, 50), + "background_bottom": (60, 80, 100), # noir vers bleu foncĂ© + # Fond des cadres + "button_idle": (50, 50, 70, 150), # Bleu sombre mĂ©tal + # Fond des boutons sĂ©lectionnĂ©s dans les popups ou menu + "button_hover": (255, 0, 255, 220), # Rose + # GĂ©nĂ©rique + "text": (255, 255, 255), # blanc + # Erreur + "error_text": (255, 0, 0), # rouge + # Avertissement + "warning_text": (255, 100, 0), # orange + # Titres + "title_text": (200, 200, 200), # gris clair + # Bordures + "border": (150, 150, 150), # Bordures grises subtiles +} + +# GĂ©nĂ©ral, rĂ©solution, overlay +def init_display(): + """Initialise l'Ă©cran et les ressources globales.""" + global OVERLAY + logger.debug("Initialisation de l'Ă©cran") + display_info = pygame.display.Info() + screen_width = display_info.current_w + screen_height = display_info.current_h + screen = pygame.display.set_mode((screen_width, screen_height)) + config.screen_width = screen_width + config.screen_height = screen_height + # Initialisation de OVERLAY + OVERLAY = pygame.Surface((screen_width, screen_height), pygame.SRCALPHA) + OVERLAY.fill((0, 0, 0, 150)) # Transparence augmentĂ©e + logger.debug(f"Écran initialisĂ© avec rĂ©solution : {screen_width}x{screen_height}") + return screen + +# Fond d'Ă©cran dĂ©gradĂ© +def draw_gradient(screen, top_color, bottom_color): + """Dessine un fond dĂ©gradĂ© vertical avec des couleurs vibrantes.""" + height = screen.get_height() + top_color = pygame.Color(*top_color) + bottom_color = pygame.Color(*bottom_color) + for y in range(height): + ratio = y / height + color = top_color.lerp(bottom_color, ratio) + pygame.draw.line(screen, color, (0, y), (screen.get_width(), y)) + +# Nouvelle fonction pour dessiner un bouton stylisĂ© +def draw_stylized_button(screen, text, x, y, width, height, selected=False): + """Dessine un bouton moderne avec effet de survol et bordure arrondie.""" + button_surface = pygame.Surface((width, height), pygame.SRCALPHA) + button_color = THEME_COLORS["button_hover"] if selected else THEME_COLORS["button_idle"] + pygame.draw.rect(button_surface, button_color, (0, 0, width, height), border_radius=12) + pygame.draw.rect(button_surface, THEME_COLORS["border"], (0, 0, width, height), 2, border_radius=12) + if selected: + glow_surface = pygame.Surface((width + 10, height + 10), pygame.SRCALPHA) + pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (5, 5, width, height), border_radius=12) + screen.blit(glow_surface, (x - 5, y - 5)) + screen.blit(button_surface, (x, y)) + text_surface = config.font.render(text, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(x + width // 2, y + height // 2)) + screen.blit(text_surface, text_rect) + +# Transition d'image lors de la sĂ©lection d'un systĂšme +def draw_validation_transition(screen, platform_index): + """Affiche une animation de transition fluide pour la sĂ©lection d’une plateforme.""" + platform_dict = config.platform_dicts[platform_index] + image = load_system_image(platform_dict) + if not image: + return + + # Dimensions originales et calcul du ratio pour prĂ©server les proportions + orig_width, orig_height = image.get_width(), image.get_height() + base_size = int(config.screen_width * 0.0781) # ~150px pour 1920p + ratio = min(base_size / orig_width, base_size / orig_height) # Maintenir les proportions + base_width = int(orig_width * ratio) + base_height = int(orig_height * ratio) + + # ParamĂštres de l'animation + start_time = pygame.time.get_ticks() + duration = 1000 # DurĂ©e augmentĂ©e Ă  1 seconde + fps = 60 + frame_time = 1000 / fps # Temps par frame en ms + + while pygame.time.get_ticks() - start_time < duration: + # Fond dĂ©gradĂ© + draw_gradient(screen, THEME_COLORS["background_top"], THEME_COLORS["background_bottom"]) + + # Calcul de l'Ă©chelle avec une courbe sinusoĂŻdale pour une transition fluide + elapsed = pygame.time.get_ticks() - start_time + progress = elapsed / duration + # Courbe sinusoĂŻdale pour une montĂ©e/descente douce + scale = 1.5 + 1.0 * math.sin(math.pi * progress) # Échelle de 1.5 Ă  2.5 + new_width = int(base_width * scale) + new_height = int(base_height * scale) + + # Redimensionner l'image en prĂ©servant les proportions + scaled_image = pygame.transform.smoothscale(image, (new_width, new_height)) + image_rect = scaled_image.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + + # Effet de fondu (opacitĂ© de 50% Ă  100% puis retour Ă  50%) + alpha = int(128 + 127 * math.cos(math.pi * progress)) # OpacitĂ© entre 128 et 255 + scaled_image.set_alpha(alpha) + + # Effet de glow nĂ©on pour l'image sĂ©lectionnĂ©e + neon_color = THEME_COLORS["neon"] # Cyan vif + padding = 24 + neon_surface = pygame.Surface((new_width + 2 * padding, new_height + 2 * padding), pygame.SRCALPHA) + pygame.draw.rect(neon_surface, neon_color + (40,), neon_surface.get_rect(), border_radius=24) + pygame.draw.rect(neon_surface, neon_color + (100,), neon_surface.get_rect().inflate(-10, -10), border_radius=18) + screen.blit(neon_surface, (image_rect.left - padding, image_rect.top - padding), special_flags=pygame.BLEND_RGBA_ADD) + + # Afficher l'image + screen.blit(scaled_image, image_rect) + pygame.display.flip() + + # ContrĂŽler la frĂ©quence de rendu + pygame.time.wait(int(frame_time)) + + # Afficher l'image finale sans effet pour une transition propre + draw_gradient(screen, THEME_COLORS["background_top"], THEME_COLORS["background_bottom"]) + final_image = pygame.transform.smoothscale(image, (base_width, base_height)) + final_image.set_alpha(255) # OpacitĂ© complĂšte + final_rect = final_image.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + screen.blit(final_image, final_rect) + pygame.display.flip() + +# Écran de chargement +def draw_loading_screen(screen): + """Affiche l’écran de chargement avec un style moderne.""" + disclaimer_lines = [ + _("welcome_message"), + _("disclaimer_line1"), + _("disclaimer_line2"), + _("disclaimer_line3"), + _("disclaimer_line4"), + _("disclaimer_line5"), + ] + + margin_horizontal = int(config.screen_width * 0.025) + padding_vertical = int(config.screen_height * 0.0185) + padding_between = int(config.screen_height * 0.0074) + border_radius = 16 + border_width = 3 + shadow_offset = 6 + + line_height = config.small_font.get_height() + padding_between + total_height = line_height * len(disclaimer_lines) - padding_between + rect_width = config.screen_width - 2 * margin_horizontal + rect_height = total_height + 2 * padding_vertical + rect_x = margin_horizontal + rect_y = int(config.screen_height * 0.0185) + + shadow_rect = pygame.Rect(rect_x + shadow_offset, rect_y + shadow_offset, rect_width, rect_height) + shadow_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA) + pygame.draw.rect(shadow_surface, (0, 0, 0, 100), shadow_surface.get_rect(), border_radius=border_radius) + screen.blit(shadow_surface, shadow_rect.topleft) + + disclaimer_rect = pygame.Rect(rect_x, rect_y, rect_width, rect_height) + disclaimer_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA) + pygame.draw.rect(disclaimer_surface, THEME_COLORS["button_idle"], disclaimer_surface.get_rect(), border_radius=border_radius) + screen.blit(disclaimer_surface, disclaimer_rect.topleft) + + pygame.draw.rect(screen, THEME_COLORS["border"], disclaimer_rect, border_width, border_radius=border_radius) + + max_text_width = rect_width - 2 * padding_vertical + for i, line in enumerate(disclaimer_lines): + wrapped_lines = wrap_text(line, config.small_font, max_text_width) + for j, wrapped_line in enumerate(wrapped_lines): + text_surface = config.small_font.render(wrapped_line, True, THEME_COLORS["title_text"]) + text_rect = text_surface.get_rect(center=( + config.screen_width // 2, + rect_y + padding_vertical + (i * len(wrapped_lines) + j + 0.5) * line_height - padding_between // 2 + )) + screen.blit(text_surface, text_rect) + + loading_y = rect_y + rect_height + int(config.screen_height * 0.0926) + text = config.small_font.render(truncate_text_middle(f"{config.current_loading_system}", config.small_font, config.screen_width - 2 * margin_horizontal), True, THEME_COLORS["text"]) + text_rect = text.get_rect(center=(config.screen_width // 2, loading_y)) + screen.blit(text, text_rect) + + progress_text = config.small_font.render(_("loading_progress").format(int(config.loading_progress)), True, THEME_COLORS["text"]) + progress_rect = progress_text.get_rect(center=(config.screen_width // 2, loading_y + int(config.screen_height * 0.0463))) + screen.blit(progress_text, progress_rect) + + bar_width = int(config.screen_width * 0.2083) + bar_height = int(config.screen_height * 0.037) + progress_width = (bar_width * config.loading_progress) / 100 + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (config.screen_width // 2 - bar_width // 2, loading_y + int(config.screen_height * 0.0926), bar_width, bar_height), border_radius=8) + pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (config.screen_width // 2 - bar_width // 2, loading_y + int(config.screen_height * 0.0926), progress_width, bar_height), border_radius=8) + +# Écran d'erreur +def draw_error_screen(screen): + """Affiche l’écran d’erreur avec un style moderne.""" + wrapped_message = wrap_text(config.error_message, config.small_font, config.screen_width - 80) + line_height = config.small_font.get_height() + 5 + text_height = len(wrapped_message) * line_height + button_height = int(config.screen_height * 0.0463) + margin_top_bottom = 20 + rect_height = text_height + button_height + 2 * margin_top_bottom + max_text_width = max([config.small_font.size(line)[0] for line in wrapped_message], default=300) + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + screen.blit(OVERLAY, (0, 0)) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_message): + text = config.small_font.render(line, True, THEME_COLORS["error_text"]) + text_rect = text.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text, text_rect) + + draw_stylized_button(screen, _("button_validate"), rect_x + rect_width // 2 - 80, rect_y + text_height + margin_top_bottom, 160, button_height, selected=True) + +# RĂ©cupĂ©rer les noms d'affichage des contrĂŽles +def get_control_display(action, default): + """RĂ©cupĂšre le nom d'affichage d'une action depuis controls_config.""" + if not config.controls_config: + logger.warning(f"controls_config vide pour l'action {action}, utilisation de la valeur par dĂ©faut") + return default + return config.controls_config.get(action, {}).get('display', default) + +# Cache pour les images des plateformes +platform_images_cache = {} + +# Grille des systĂšmes 3x3 +def draw_platform_grid(screen): + """Affiche la grille des plateformes avec un style moderne et fluide.""" + global platform_images_cache + + if not config.platforms or config.selected_platform >= len(config.platforms): + platform_name = _("platform_no_platform") + logger.warning("Aucune plateforme ou selected_platform hors limites") + else: + platform = config.platforms[config.selected_platform] + platform_name = config.platform_names.get(platform, platform) + + # Affichage du titre avec animation subtile + title_text = f"{platform_name}" + title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20)) + title_rect_inflated = title_rect.inflate(60, 30) + title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10) + + # Effet de pulsation subtil pour le titre - calculĂ© une seule fois par frame + current_time = pygame.time.get_ticks() + pulse_factor = 0.05 * (1 + math.sin(current_time / 500)) + title_glow = pygame.Surface((title_rect_inflated.width + 10, title_rect_inflated.height + 10), pygame.SRCALPHA) + pygame.draw.rect(title_glow, THEME_COLORS["neon"] + (int(40 * pulse_factor),), + title_glow.get_rect(), border_radius=14) + screen.blit(title_glow, (title_rect_inflated.left - 5, title_rect_inflated.top - 5)) + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) + screen.blit(title_surface, title_rect) + + # Configuration de la grille - calculĂ©e une seule fois + margin_left = int(config.screen_width * 0.026) + margin_right = int(config.screen_width * 0.026) + margin_top = int(config.screen_height * 0.140) + margin_bottom = int(config.screen_height * 0.0648) + num_cols = 3 + num_rows = 4 + systems_per_page = num_cols * num_rows + + available_width = config.screen_width - margin_left - margin_right + available_height = config.screen_height - margin_top - margin_bottom + + col_width = available_width // num_cols + row_height = available_height // num_rows + + x_positions = [margin_left + col_width * i + col_width // 2 for i in range(num_cols)] + y_positions = [margin_top + row_height * i + row_height // 2 for i in range(num_rows)] + + # Affichage des indicateurs de page si nĂ©cessaire + total_pages = (len(config.platforms) + systems_per_page - 1) // systems_per_page + if total_pages > 1: + page_indicator_text = _("platform_page").format(config.current_page + 1, total_pages) + page_indicator = config.small_font.render(page_indicator_text, True, THEME_COLORS["text"]) + page_rect = page_indicator.get_rect(center=(config.screen_width // 2, config.screen_height - margin_bottom // 2)) + screen.blit(page_indicator, page_rect) + + # Calculer une seule fois la pulsation pour les Ă©lĂ©ments sĂ©lectionnĂ©s + pulse = 0.1 * math.sin(current_time / 300) + glow_intensity = 40 + int(30 * math.sin(current_time / 300)) + + # PrĂ©-calcul des images pour optimiser le rendu + start_idx = config.current_page * systems_per_page + for idx in range(start_idx, start_idx + systems_per_page): + if idx >= len(config.platforms): + break + grid_idx = idx - start_idx + row = grid_idx // num_cols + col = grid_idx % num_cols + x = x_positions[col] + y = y_positions[row] + + # Animation fluide pour l'item sĂ©lectionnĂ© + is_selected = idx == config.selected_platform + scale_base = 1.5 if is_selected else 1.0 + scale = scale_base + pulse if is_selected else scale_base + + platform_dict = config.platform_dicts[idx] + platform_id = platform_dict.get("platform", str(idx)) + + # Utiliser le cache d'images pour Ă©viter de recharger/redimensionner Ă  chaque frame + cache_key = f"{platform_id}_{scale:.2f}" + if cache_key not in platform_images_cache: + image = load_system_image(platform_dict) + if image: + orig_width, orig_height = image.get_width(), image.get_height() + max_size = int(min(col_width, row_height) * scale * 1.1) # LĂ©gĂšrement plus grand que la cellule + ratio = min(max_size / orig_width, max_size / orig_height) + new_width = int(orig_width * ratio) + new_height = int(orig_height * ratio) + scaled_image = pygame.transform.smoothscale(image, (new_width, new_height)) + platform_images_cache[cache_key] = { + "image": scaled_image, + "width": new_width, + "height": new_height, + "last_used": current_time + } + else: + continue + else: + # Mettre Ă  jour le timestamp de derniĂšre utilisation + platform_images_cache[cache_key]["last_used"] = current_time + scaled_image = platform_images_cache[cache_key]["image"] + new_width = platform_images_cache[cache_key]["width"] + new_height = platform_images_cache[cache_key]["height"] + + image_rect = scaled_image.get_rect(center=(x, y)) + + # Effet visuel amĂ©liorĂ© pour l'item sĂ©lectionnĂ© + if is_selected: + neon_color = THEME_COLORS["neon"] + border_radius = 12 + padding = 12 + rect_width = image_rect.width + 2 * padding + rect_height = image_rect.height + 2 * padding + + # Effet de glow dynamique + neon_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA) + pygame.draw.rect(neon_surface, neon_color + (glow_intensity,), neon_surface.get_rect(), border_radius=border_radius) + pygame.draw.rect(neon_surface, neon_color + (100,), neon_surface.get_rect().inflate(-10, -10), border_radius=border_radius) + pygame.draw.rect(neon_surface, neon_color + (200,), neon_surface.get_rect().inflate(-20, -20), width=1, border_radius=border_radius) + screen.blit(neon_surface, (image_rect.left - padding, image_rect.top - padding), special_flags=pygame.BLEND_RGBA_ADD) + + # Fond pour toutes les images + background_surface = pygame.Surface((image_rect.width + 10, image_rect.height + 10), pygame.SRCALPHA) + bg_alpha = 220 if is_selected else 180 # Plus opaque pour l'item sĂ©lectionnĂ© + pygame.draw.rect(background_surface, THEME_COLORS["fond_image"] + (bg_alpha,), background_surface.get_rect(), border_radius=12) + screen.blit(background_surface, (image_rect.left - 5, image_rect.top - 5)) + + # Affichage de l'image avec un lĂ©ger effet de transparence pour les items non sĂ©lectionnĂ©s + if not is_selected: + # Appliquer la transparence seulement si nĂ©cessaire + temp_image = scaled_image.copy() + temp_image.set_alpha(220) + screen.blit(temp_image, image_rect) + else: + screen.blit(scaled_image, image_rect) + + # Nettoyer le cache pĂ©riodiquement (garder seulement les images utilisĂ©es rĂ©cemment) + if len(platform_images_cache) > 50: # Limite arbitraire pour Ă©viter une croissance excessive + current_time = pygame.time.get_ticks() + cache_timeout = 30000 # 30 secondes + keys_to_remove = [k for k, v in platform_images_cache.items() + if current_time - v["last_used"] > cache_timeout] + for key in keys_to_remove: + del platform_images_cache[key] + +# Liste des jeux +def draw_game_list(screen): + """Affiche la liste des jeux avec un style moderne.""" + platform = config.platforms[config.current_platform] + platform_name = config.platform_names.get(platform, platform) + games = config.filtered_games if config.filter_active or config.search_mode else config.games + game_count = len(games) + + if not games: + logger.debug("Aucune liste de jeux disponible") + message = _("game_no_games") + lines = wrap_text(message, config.font, config.screen_width - 80) + line_height = config.font.get_height() + 5 + text_height = len(lines) * line_height + margin_top_bottom = 20 + rect_height = text_height + 2 * margin_top_bottom + max_text_width = max([config.font.size(line)[0] for line in lines], default=300) + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + screen.blit(OVERLAY, (0, 0)) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(lines): + text_surface = config.font.render(line, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text_surface, text_rect) + return + + line_height = config.small_font.get_height() + 10 + margin_top_bottom = 20 + extra_margin_top = 20 + extra_margin_bottom = 60 + title_height = config.title_font.get_height() + 20 + + available_height = config.screen_height - title_height - extra_margin_top - extra_margin_bottom - 2 * margin_top_bottom + items_per_page = available_height // line_height + + rect_height = items_per_page * line_height + 2 * margin_top_bottom + rect_width = int(0.95 * config.screen_width) + rect_x = (config.screen_width - rect_width) // 2 + rect_y = title_height + extra_margin_top + (config.screen_height - title_height - extra_margin_top - extra_margin_bottom - rect_height) // 2 + + config.scroll_offset = max(0, min(config.scroll_offset, max(0, len(games) - items_per_page))) + if config.current_game < config.scroll_offset: + config.scroll_offset = config.current_game + elif config.current_game >= config.scroll_offset + items_per_page: + config.scroll_offset = config.current_game - items_per_page + 1 + + screen.blit(OVERLAY, (0, 0)) + + if config.search_mode: + search_text = _("game_search").format(config.search_query + "_") + title_surface = config.search_font.render(search_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20)) + title_rect_inflated = title_rect.inflate(60, 30) + title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) + screen.blit(title_surface, title_rect) + elif config.filter_active: + filter_text = _("game_filter").format(config.search_query) + title_surface = config.font.render(filter_text, True, THEME_COLORS["fond_lignes"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20)) + title_rect_inflated = title_rect.inflate(60, 30) + title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) + screen.blit(title_surface, title_rect) + else: + title_text = _("game_count").format(platform_name, game_count) + title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20)) + title_rect_inflated = title_rect.inflate(60, 30) + title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) + screen.blit(title_surface, title_rect) + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i in range(config.scroll_offset, min(config.scroll_offset + items_per_page, len(games))): + game_name = games[i][0] if isinstance(games[i], (list, tuple)) else games[i] + color = THEME_COLORS["fond_lignes"] if i == config.current_game else THEME_COLORS["text"] + game_text = truncate_text_middle(game_name, config.small_font, rect_width - 40) + text_surface = config.small_font.render(game_text, True, color) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + (i - config.scroll_offset) * line_height + line_height // 2)) + if i == config.current_game: + glow_surface = pygame.Surface((text_rect.width + 20, text_rect.height + 10), pygame.SRCALPHA) + pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (10, 5, text_rect.width, text_rect.height), border_radius=8) + screen.blit(glow_surface, (text_rect.left - 10, text_rect.top - 5)) + screen.blit(text_surface, text_rect) + + if len(games) > items_per_page: + try: + draw_game_scrollbar( + screen, + config.scroll_offset, + len(games), + items_per_page, + rect_x + rect_width - 10, + rect_y, + rect_height + ) + except NameError as e: + logger.error(f"Erreur : draw_game_scrollbar non dĂ©fini: {str(e)}") + +# Barre de dĂ©filement des jeux +def draw_game_scrollbar(screen, scroll_offset, total_items, visible_items, x, y, height): + """Affiche la barre de dĂ©filement pour la liste des jeux.""" + if total_items <= visible_items: + return + game_area_height = height + scrollbar_height = game_area_height * (visible_items / total_items) + scrollbar_y = y + (game_area_height - scrollbar_height) * (scroll_offset / max(1, total_items - visible_items)) + pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (x, scrollbar_y, 15, scrollbar_height), border_radius=4) + +def draw_history_list(screen): + # logger.debug(f"Dessin historique, history={config.history}, needs_redraw={config.needs_redraw}") + history = config.history if hasattr(config, 'history') else load_history() + history_count = len(history) + + # Define column widths as percentages of available space + column_width_percentages = { + "platform": 0.25, # platform column + "game_name": 0.50, # game name column + "status": 0.25 # status column + } + available_width = int(0.95 * config.screen_width - 60) # Total available width for columns + col_platform_width = int(available_width * column_width_percentages["platform"]) + col_game_width = int(available_width * column_width_percentages["game_name"]) + col_status_width = int(available_width * column_width_percentages["status"]) + rect_width = int(0.95 * config.screen_width) + + line_height = config.small_font.get_height() + 10 + header_height = line_height + margin_top_bottom = 20 + extra_margin_top = 40 + extra_margin_bottom = 80 + title_height = config.title_font.get_height() + 20 + + if not history: + logger.debug("Aucun historique disponible") + message = _("history_empty") + lines = wrap_text(message, config.font, config.screen_width - 80) + line_height = config.font.get_height() + 5 + text_height = len(lines) * line_height + rect_height = text_height + 2 * margin_top_bottom + max_text_width = max([config.font.size(line)[0] for line in lines], default=300) + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + screen.blit(OVERLAY, (0, 0)) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(lines): + text_surface = config.font.render(line, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text_surface, text_rect) + return + + available_height = config.screen_height - title_height - extra_margin_top - extra_margin_bottom - 2 * margin_top_bottom + items_per_page = available_height // line_height + + rect_height = header_height + items_per_page * line_height + 2 * margin_top_bottom + rect_x = (config.screen_width - rect_width) // 2 + rect_y = title_height + extra_margin_top + (config.screen_height - title_height - extra_margin_top - extra_margin_bottom - rect_height) // 2 + + config.history_scroll_offset = max(0, min(config.history_scroll_offset, max(0, len(history) - items_per_page))) + if config.current_history_item < config.history_scroll_offset: + config.history_scroll_offset = config.current_history_item + elif config.current_history_item >= config.history_scroll_offset + items_per_page: + config.history_scroll_offset = config.current_history_item - items_per_page + 1 + + screen.blit(OVERLAY, (0, 0)) + + title_text = _("history_title").format(history_count) + title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20)) + title_rect_inflated = title_rect.inflate(60, 30) + title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) + screen.blit(title_surface, title_rect) + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + headers = [_("history_column_system"), _("history_column_game"), _("history_column_status")] + header_y = rect_y + margin_top_bottom + header_height // 2 + header_x_positions = [ + rect_x + 20 + col_platform_width // 2, + rect_x + 20 + col_platform_width + col_game_width // 2, + rect_x + 20 + col_platform_width + col_game_width + col_status_width // 2 + ] + for header, x_pos in zip(headers, header_x_positions): + text_surface = config.small_font.render(header, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(x_pos, header_y)) + screen.blit(text_surface, text_rect) + + separator_y = rect_y + margin_top_bottom + header_height + pygame.draw.line(screen, THEME_COLORS["border"], (rect_x + 20, separator_y), (rect_x + rect_width - 20, separator_y), 2) + + for idx, i in enumerate(range(config.history_scroll_offset, min(config.history_scroll_offset + items_per_page, len(history)))): + entry = history[i] + platform = entry.get("platform", "Inconnu") + game_name = entry.get("game_name", "Inconnu") + status = entry.get("status", "Inconnu") + progress = entry.get("progress", 0) + # Personnaliser l'affichage du statut + if status in ["TĂ©lĂ©chargement", "downloading"]: + status_text = _("history_status_downloading").format(progress) + # logger.debug(f"Affichage progression: {progress:.1f}% pour {game_name}, status={status_text}") + elif status == "Extracting": + status_text = _("history_status_extracting").format(progress) + # logger.debug(f"Affichage extraction: {progress:.1f}% pour {game_name}, status={status_text}") + elif status == "Download_OK": + status_text = _("history_status_completed") + # S'assurer que le pourcentage est entre 0 et 100 + progress = max(0, min(100, progress)) + # Personnaliser l'affichage du statut + if status in ["TĂ©lĂ©chargement", "downloading"]: + status_text = _("history_status_downloading").format(progress) + # logger.debug(f"Affichage progression: {progress:.1f}% pour {game_name}, status={status_text}") + elif status == "Extracting": + status_text = _("history_status_extracting").format(progress) + # logger.debug(f"Affichage extraction: {progress:.1f}% pour {game_name}, status={status_text}") + elif status == "Download_OK": + status_text = _("history_status_completed") + # logger.debug(f"Affichage terminĂ©: {game_name}, status={status_text}") + elif status == "Erreur": + status_text = _("history_status_error").format(entry.get('message', 'Échec')) + logger.debug(f"Affichage erreur: {game_name}, status={status_text}") + else: + status_text = status + logger.debug(f"Affichage statut inconnu: {game_name}, status={status_text}") + + color = THEME_COLORS["fond_lignes"] if i == config.current_history_item else THEME_COLORS["text"] + platform_text = truncate_text_end(platform, config.small_font, col_platform_width - 10) + game_text = truncate_text_end(game_name, config.small_font, col_game_width - 10) + status_text = truncate_text_middle(status_text, config.small_font, col_status_width - 10, is_filename=False) + + y_pos = rect_y + margin_top_bottom + header_height + idx * line_height + line_height // 2 + platform_surface = config.small_font.render(platform_text, True, color) + game_surface = config.small_font.render(game_text, True, color) + status_surface = config.small_font.render(status_text, True, color) + + platform_rect = platform_surface.get_rect(center=(header_x_positions[0], y_pos)) + game_rect = game_surface.get_rect(center=(header_x_positions[1], y_pos)) + status_rect = status_surface.get_rect(center=(header_x_positions[2], y_pos)) + + if i == config.current_history_item: + glow_surface = pygame.Surface((rect_width - 40, line_height), pygame.SRCALPHA) + pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (0, 0, rect_width - 40, line_height), border_radius=8) + screen.blit(glow_surface, (rect_x + 20, y_pos - line_height // 2)) + + screen.blit(platform_surface, platform_rect) + screen.blit(game_surface, game_rect) + screen.blit(status_surface, status_rect) + + if len(history) > items_per_page: + try: + draw_history_scrollbar( + screen, + config.history_scroll_offset, + len(history), + items_per_page, + rect_x + rect_width - 10, + rect_y, + rect_height + ) + except NameError as e: + logger.error(f"Erreur : draw_history_scrollbar non dĂ©fini: {str(e)}") + +# Barre de dĂ©filement de l'historique +def draw_history_scrollbar(screen, scroll_offset, total_items, visible_items, x, y, height): + """Affiche la barre de dĂ©filement avec un style moderne.""" + if total_items <= visible_items: + return + game_area_height = height + scrollbar_height = game_area_height * (visible_items / total_items) - 10 + scrollbar_y = y + (game_area_height - scrollbar_height) * (scroll_offset / max(1, total_items - visible_items)) + 10 + pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (x, scrollbar_y, 5, scrollbar_height), border_radius=4) + +# Écran confirmation vider historique +def draw_clear_history_dialog(screen): + """Affiche la boĂźte de dialogue de confirmation pour vider l'historique.""" + screen.blit(OVERLAY, (0, 0)) + + message = _("confirm_clear_history") + wrapped_message = wrap_text(message, config.font, config.screen_width - 80) + line_height = config.font.get_height() + 5 + text_height = len(wrapped_message) * line_height + button_height = int(config.screen_height * 0.0463) + margin_top_bottom = 20 + rect_height = text_height + button_height + 2 * margin_top_bottom + max_text_width = max([config.font.size(line)[0] for line in wrapped_message], default=300) + rect_width = max_text_width + 150 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_message): + text = config.font.render(line, True, THEME_COLORS["text"]) + text_rect = text.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text, text_rect) + + draw_stylized_button(screen, _("button_yes"), rect_x + rect_width // 2 - 180, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.confirm_clear_selection == 1) + draw_stylized_button(screen, _("button_no"), rect_x + rect_width // 2 + 20, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.confirm_clear_selection == 0) + +# Affichage du clavier virtuel sur non-PC +def draw_virtual_keyboard(screen): + """Affiche un clavier virtuel avec un style moderne.""" + keyboard_layout = [ + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + ['A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M'], + ['W', 'X', 'C', 'V', 'B', 'N'] + ] + key_width = int(config.screen_width * 0.03125) + key_height = int(config.screen_height * 0.0556) + key_spacing = int(config.screen_width * 0.0052) + keyboard_width = len(keyboard_layout[0]) * (key_width + key_spacing) - key_spacing + keyboard_height = len(keyboard_layout) * (key_height + key_spacing) - key_spacing + start_x = (config.screen_width - keyboard_width) // 2 + search_bottom_y = int(config.screen_height * 0.111) + (config.search_font.get_height() + 40) // 2 + controls_y = config.screen_height - int(config.screen_height * 0.037) + available_height = controls_y - search_bottom_y + start_y = search_bottom_y + (available_height - keyboard_height - 40) // 2 + + keyboard_rect = pygame.Rect(start_x - 20, start_y - 20, keyboard_width + 40, keyboard_height + 40) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], keyboard_rect, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], keyboard_rect, 2, border_radius=12) + + for row_idx, row in enumerate(keyboard_layout): + for col_idx, key in enumerate(row): + x = start_x + col_idx * (key_width + key_spacing) + y = start_y + row_idx * (key_height + key_spacing) + key_rect = pygame.Rect(x, y, key_width, key_height) + if (row_idx, col_idx) == config.selected_key: + pygame.draw.rect(screen, THEME_COLORS["fond_lignes"] + (150,), key_rect, border_radius=8) + else: + pygame.draw.rect(screen, THEME_COLORS["button_idle"], key_rect, border_radius=8) + pygame.draw.rect(screen, THEME_COLORS["border"], key_rect, 1, border_radius=8) + text = config.font.render(key, True, THEME_COLORS["text"]) + text_rect = text.get_rect(center=key_rect.center) + screen.blit(text, text_rect) + +# Écran de progression de tĂ©lĂ©chargement/extraction +def draw_progress_screen(screen): + """Affiche l'Ă©cran de progression des tĂ©lĂ©chargements avec un style moderne.""" + if not config.download_tasks: + logger.debug("Aucune tĂąche de tĂ©lĂ©chargement active") + return + + task = list(config.download_tasks.keys())[0] + game_name = config.download_tasks[task][2] + url = config.download_tasks[task][1] + progress = config.download_progress.get(url, {"downloaded_size": 0, "total_size": 0, "status": "TĂ©lĂ©chargement", "progress_percent": 0}) + status = progress.get("status", "TĂ©lĂ©chargement") + downloaded_size = progress["downloaded_size"] + total_size = progress["total_size"] + progress_percent = progress["progress_percent"] + # S'assurer que le pourcentage est entre 0 et 100 + progress_percent = max(0, min(100, progress_percent)) + + screen.blit(OVERLAY, (0, 0)) + + title_text = _("download_status").format(status, truncate_text_middle(game_name, config.font, config.screen_width - 200)) + title_lines = wrap_text(title_text, config.font, config.screen_width - 80) + line_height = config.font.get_height() + 5 + text_height = len(title_lines) * line_height + margin_top_bottom = 20 + bar_height = int(config.screen_height * 0.0278) + percent_height = config.progress_font.get_height() + 5 + rect_height = text_height + bar_height + percent_height + 3 * margin_top_bottom + max_text_width = max([config.font.size(line)[0] for line in title_lines], default=300) + bar_width = max_text_width + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(title_lines): + title_render = config.font.render(line, True, THEME_COLORS["text"]) + title_rect = title_render.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(title_render, title_rect) + + bar_y = rect_y + text_height + margin_top_bottom + progress_width = 0 + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x + 20, bar_y, bar_width, bar_height), border_radius=8) + if total_size > 0: + # Limiter le pourcentage entre 0 et 100 pour l'affichage de la barre + progress_width = int(bar_width * (min(100, max(0, progress_percent)) / 100)) + + +# Écran popup rĂ©sultat tĂ©lĂ©chargement +def draw_popup_result_download(screen, message, is_error): + """Affiche une popup flottante centrĂ©e avec un message de rĂ©sultat et un compte Ă  rebours.""" + if message is None: + message = _("download_canceled") + logger.debug(f"Message popup : {message}, is_error={is_error}") + + screen.blit(OVERLAY, (0, 0)) + + popup_width = int(config.screen_width * 0.8) + line_height = config.small_font.get_height() + 10 + wrapped_message = wrap_text(message, config.small_font, popup_width - 40) + text_height = len(wrapped_message) * line_height + margin_top_bottom = 20 + popup_height = text_height + 2 * margin_top_bottom + line_height + popup_x = (config.screen_width - popup_width) // 2 + popup_y = (config.screen_height - popup_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (popup_x, popup_y, popup_width, popup_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (popup_x, popup_y, popup_width, popup_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_message): + text_surface = config.small_font.render(line, True, THEME_COLORS["error_text"] if is_error else THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, popup_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text_surface, text_rect) + + remaining_time = 3 + countdown_text = _("popup_countdown").format(remaining_time, 's' if remaining_time != 1 else '') + countdown_surface = config.small_font.render(countdown_text, True, THEME_COLORS["text"]) + countdown_rect = countdown_surface.get_rect(center=(config.screen_width // 2, popup_y + margin_top_bottom + len(wrapped_message) * line_height + line_height // 2)) + screen.blit(countdown_surface, countdown_rect) + +# Écran avertissement extension non supportĂ©e tĂ©lĂ©chargement +def draw_extension_warning(screen): + """Affiche un avertissement pour une extension non reconnue ou un fichier ZIP.""" + if not config.pending_download: + logger.error("config.pending_download est None ou vide dans extension_warning") + message = "Erreur : Aucun tĂ©lĂ©chargement en attente." + is_zip = False + game_name = "Inconnu" + else: + url, platform, game_name, is_zip_non_supported = config.pending_download + logger.debug(f"config.pending_download: url={url}, platform={platform}, game_name={game_name}, is_zip_non_supported={is_zip_non_supported}") + is_zip = is_zip_non_supported + if not game_name: + game_name = "Inconnu" + logger.warning("game_name vide, utilisation de 'Inconnu'") + + if is_zip: + message = _("extension_warning_zip").format(game_name) + else: + message = _("extension_warning_unsupported").format(game_name) + + max_width = config.screen_width - 80 + lines = wrap_text(message, config.font, max_width) + logger.debug(f"Lignes gĂ©nĂ©rĂ©es : {lines}") + + try: + line_height = config.font.get_height() + 5 + text_height = len(lines) * line_height + button_height = int(config.screen_height * 0.0463) + margin_top_bottom = 20 + rect_height = text_height + button_height + 2 * margin_top_bottom + max_text_width = max([config.font.size(line)[0] for line in lines], default=300) + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + screen.blit(OVERLAY, (0, 0)) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(lines): + text_surface = config.font.render(line, True, THEME_COLORS["warning_text"]) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text_surface, text_rect) + + draw_stylized_button(screen, _("button_yes"), rect_x + rect_width // 2 - 180, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.extension_confirm_selection == 1) + draw_stylized_button(screen, _("button_no"), rect_x + rect_width // 2 + 20, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.extension_confirm_selection == 0) + + except Exception as e: + logger.error(f"Erreur lors du rendu de extension_warning : {str(e)}") + error_message = "Erreur d'affichage de l'avertissement." + wrapped_error = wrap_text(error_message, config.font, config.screen_width - 80) + line_height = config.font.get_height() + 5 + rect_height = len(wrapped_error) * line_height + 2 * 20 + max_text_width = max([config.font.size(line)[0] for line in wrapped_error], default=300) + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + screen.blit(OVERLAY, (0, 0)) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_error): + error_surface = config.font.render(line, True, THEME_COLORS["error_text"]) + error_rect = error_surface.get_rect(center=(config.screen_width // 2, rect_y + 20 + i * line_height + line_height // 2)) + screen.blit(error_surface, error_rect) + +# Affichage des contrĂŽles en bas de page +def draw_controls(screen, menu_state): + """Affiche les contrĂŽles sur une seule ligne en bas de l’écran.""" + start_button = get_control_display('start', 'START') + history_button = get_control_display('history', 'H') + filter_button = get_control_display('filter', 'F') + control_text = _("footer_version").format(config.app_version, start_button, history_button, filter_button) + max_width = config.screen_width - 40 + wrapped_controls = wrap_text(control_text, config.small_font, max_width) + line_height = config.small_font.get_height() + 5 + rect_height = len(wrapped_controls) * line_height + 20 + rect_y = config.screen_height - rect_height - 5 + rect_x = (config.screen_width - max_width) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, max_width, rect_height), border_radius=8) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, max_width, rect_height), 1, border_radius=8) + + for i, line in enumerate(wrapped_controls): + text_surface = config.small_font.render(line, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + 10 + i * line_height + line_height // 2)) + screen.blit(text_surface, text_rect) + +# Menu pause +def draw_language_menu(screen): + """Dessine le menu de sĂ©lection de langue avec un style moderne.""" + from language import get_available_languages, get_language_name + + screen.blit(OVERLAY, (0, 0)) + + # Obtenir les langues disponibles + available_languages = get_available_languages() + + if not available_languages: + logger.error("Aucune langue disponible") + return + + # Titre + title_text = _("language_select_title") + title_surface = config.font.render(title_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, config.screen_height // 4)) + + # Fond du titre + title_bg_rect = title_rect.inflate(40, 20) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_bg_rect, border_radius=10) + pygame.draw.rect(screen, THEME_COLORS["border"], title_bg_rect, 2, border_radius=10) + screen.blit(title_surface, title_rect) + + # Options de langue + button_height = 60 + button_width = 300 + button_spacing = 20 + + total_height = len(available_languages) * (button_height + button_spacing) - button_spacing + start_y = (config.screen_height - total_height) // 2 + + for i, lang_code in enumerate(available_languages): + # Obtenir le nom de la langue + lang_name = get_language_name(lang_code) + + # Position du bouton + button_x = (config.screen_width - button_width) // 2 + button_y = start_y + i * (button_height + button_spacing) + + # Dessiner le bouton + button_color = THEME_COLORS["button_hover"] if i == config.selected_language_index else THEME_COLORS["button_idle"] + pygame.draw.rect(screen, button_color, (button_x, button_y, button_width, button_height), border_radius=10) + pygame.draw.rect(screen, THEME_COLORS["border"], (button_x, button_y, button_width, button_height), 2, border_radius=10) + + # Texte du bouton + text_surface = config.font.render(lang_name, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(button_x + button_width // 2, button_y + button_height // 2)) + screen.blit(text_surface, text_rect) + + # Instructions + instruction_text = _("language_select_instruction") + instruction_surface = config.small_font.render(instruction_text, True, THEME_COLORS["text"]) + instruction_rect = instruction_surface.get_rect(center=(config.screen_width // 2, config.screen_height - 50)) + screen.blit(instruction_surface, instruction_rect) + +def draw_pause_menu(screen, selected_option): + """Dessine le menu pause avec un style moderne.""" + screen.blit(OVERLAY, (0, 0)) + + options = [ + _("menu_controls"), + _("menu_remap_controls"), + _("menu_history"), + _("menu_language"), + _("menu_redownload_cache"), + _("menu_quit") + ] + + menu_width = int(config.screen_width * 0.8) + line_height = config.font.get_height() + 10 + button_height = int(config.screen_height * 0.0463) + margin_top_bottom = 20 + menu_height = len(options) * (button_height + 10) + 2 * margin_top_bottom + menu_x = (config.screen_width - menu_width) // 2 + menu_y = (config.screen_height - menu_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (menu_x, menu_y, menu_width, menu_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (menu_x, menu_y, menu_width, menu_height), 2, border_radius=12) + + for i, option in enumerate(options): + draw_stylized_button( + screen, + option, + menu_x + 20, + menu_y + margin_top_bottom + i * (button_height + 10), + menu_width - 40, + button_height, + selected=i == selected_option + ) + +# Menu aide contrĂŽles +def draw_controls_help(screen, previous_state): + """Affiche la liste des contrĂŽles avec un style moderne.""" + # DĂ©finir les noms d'actions traduits en dehors des f-strings pour Ă©viter les problĂšmes de syntaxe + confirm_text = _("controls_action_confirm") + cancel_text = _("controls_action_cancel") + start_text = _("controls_action_start") + progress_text = _("controls_action_progress") + up_text = _("controls_action_up") + down_text = _("controls_action_down") + page_up_text = _("controls_action_page_up") + page_down_text = _("controls_action_page_down") + filter_text = _("controls_action_filter") + history_text = _("controls_action_history") + delete_text = _("controls_action_delete") + space_text = _("controls_action_space") + + common_controls = { + "confirm": lambda action: f"{get_control_display('confirm', confirm_text)} : {action}", + "cancel": lambda action: f"{get_control_display('cancel', cancel_text)} : {action}", + "start": lambda: f"{get_control_display('start', start_text)} : {start_text}", + "progress": lambda action: f"{get_control_display('progress', progress_text)} : {action}", + "up": lambda action: f"{get_control_display('up', up_text)} : {action}", + "down": lambda action: f"{get_control_display('down', down_text)} : {action}", + "page_up": lambda action: f"{get_control_display('page_up', page_up_text)} : {action}", + "page_down": lambda action: f"{get_control_display('page_down', page_down_text)} : {action}", + "filter": lambda action: f"{get_control_display('filter', filter_text)} : {action}", + "history": lambda action: f"{get_control_display('history', history_text)} : {action}", + "delete": lambda: f"{get_control_display('delete', delete_text)} : {delete_text}", + "space": lambda: f"{get_control_display('space', space_text)} : {space_text}" + } + + # Utiliser des variables pour les traductions d'actions + action_translations = { + "retry": _("action_retry"), + "quit": _("action_quit"), + "select": _("action_select"), + "history": _("action_history"), + "progress": _("action_progress"), + "download": _("action_download"), + "filter": _("action_filter"), + "cancel": _("action_cancel"), + "back": _("action_back"), + "navigate": _("action_navigate"), + "page": _("action_page"), + "cancel_download": _("action_cancel_download"), + "background": _("action_background"), + "confirm": _("action_confirm"), + "redownload": _("action_redownload"), + "clear_history": _("action_clear_history") + } + + state_controls = { + "error": [ + common_controls["confirm"](action_translations["retry"]), + common_controls["cancel"](action_translations["quit"]) + ], + "platform": [ + common_controls["confirm"](action_translations["select"]), + common_controls["cancel"](action_translations["quit"]), + common_controls["start"](), + common_controls["history"](action_translations["history"]), + *( [common_controls["progress"](action_translations["progress"])] if config.download_tasks else []) + ], + "game": [ + common_controls["confirm"](action_translations["select"] if config.search_mode else action_translations["download"]), + common_controls["filter"](action_translations["filter"]), + common_controls["cancel"](action_translations["cancel"] if config.search_mode else action_translations["back"]), + common_controls["history"](action_translations["history"]), + *( [ + common_controls["delete"](), + common_controls["space"]() + ] if config.search_mode and config.is_non_pc else []), + *( [ + f"{common_controls['up'](action_translations['navigate'])} / {common_controls['down'](action_translations['navigate'])}", + f"{common_controls['page_up'](action_translations['page'])} / {common_controls['page_down'](action_translations['page'])}", + common_controls["filter"](action_translations["filter"]) + ] if not config.is_non_pc or not config.search_mode else []), + common_controls["start"](), + *( [common_controls["progress"](action_translations["progress"])] if config.download_tasks and not config.search_mode else []) + ], + "download_progress": [ + common_controls["cancel"](action_translations["cancel_download"]), + common_controls["progress"](action_translations["background"]), + common_controls["start"]() + ], + "download_result": [ + common_controls["confirm"](action_translations["back"]) + ], + "confirm_exit": [ + common_controls["confirm"](action_translations["confirm"]) + ], + "extension_warning": [ + common_controls["confirm"](action_translations["confirm"]) + ], + "history": [ + common_controls["confirm"](action_translations["redownload"]), + common_controls["cancel"](action_translations["back"]), + common_controls["progress"](action_translations["clear_history"]), + f"{common_controls['up'](action_translations['navigate'])} / {common_controls['down'](action_translations['navigate'])}", + f"{common_controls['page_up'](action_translations['page'])} / {common_controls['page_down'](action_translations['page'])}", + common_controls["start"]() + ] + } + + controls = state_controls.get(previous_state, []) + if not controls: + return + + screen.blit(OVERLAY, (0, 0)) + + max_width = config.screen_width - 80 + wrapped_controls = [] + current_line = "" + for control in controls: + test_line = f"{current_line} | {control}" if current_line else control + if config.font.size(test_line)[0] <= max_width: + current_line = test_line + else: + wrapped_controls.append(current_line) + current_line = control + if current_line: + wrapped_controls.append(current_line) + + line_height = config.font.get_height() + 10 + popup_width = max_width + 40 + popup_height = len(wrapped_controls) * line_height + 60 + popup_x = (config.screen_width - popup_width) // 2 + popup_y = (config.screen_height - popup_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (popup_x, popup_y, popup_width, popup_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (popup_x, popup_y, popup_width, popup_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_controls): + text = config.font.render(line, True, THEME_COLORS["text"]) + text_rect = text.get_rect(center=(config.screen_width // 2, popup_y + 40 + i * line_height)) + screen.blit(text, text_rect) + +# Menu Quitter Appli +def draw_confirm_dialog(screen): + """Affiche la boĂźte de dialogue de confirmation pour quitter.""" + global OVERLAY + if OVERLAY is None or OVERLAY.get_size() != (config.screen_width, config.screen_height): + OVERLAY = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + OVERLAY.fill((0, 0, 0, 150)) + logger.debug("OVERLAY recréé dans draw_confirm_dialog") + + screen.blit(OVERLAY, (0, 0)) + message = _("confirm_exit") + wrapped_message = wrap_text(message, config.font, config.screen_width - 80) + line_height = config.font.get_height() + 5 + text_height = len(wrapped_message) * line_height + button_height = int(config.screen_height * 0.0463) + margin_top_bottom = 20 + rect_height = text_height + button_height + 2 * margin_top_bottom + max_text_width = max([config.font.size(line)[0] for line in wrapped_message], default=300) + rect_width = max_text_width + 150 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_message): + text = config.font.render(line, True, THEME_COLORS["text"]) + text_rect = text.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text, text_rect) + + draw_stylized_button(screen, _("button_yes"), rect_x + rect_width // 2 - 180, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.confirm_selection == 1) + draw_stylized_button(screen, _("button_no"), rect_x + rect_width // 2 + 20, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.confirm_selection == 0) + +# draw_redownload_game_cache_dialog +def draw_redownload_game_cache_dialog(screen): + """Affiche la boĂźte de dialogue de confirmation pour retĂ©lĂ©charger le cache des jeux.""" + global OVERLAY + if OVERLAY is None or OVERLAY.get_size() != (config.screen_width, config.screen_height): + OVERLAY = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + OVERLAY.fill((0, 0, 0, 150)) + logger.debug("OVERLAY recréé dans draw_redownload_game_cache_dialog") + + screen.blit(OVERLAY, (0, 0)) + message = _("confirm_redownload_cache") + wrapped_message = wrap_text(message, config.small_font, config.screen_width - 80) + line_height = config.small_font.get_height() + 5 + text_height = len(wrapped_message) * line_height + button_height = int(config.screen_height * 0.0463) + margin_top_bottom = 20 + rect_height = text_height + button_height + 2 * margin_top_bottom + max_text_width = max([config.small_font.size(line)[0] for line in wrapped_message], default=300) + rect_width = max_text_width + 80 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12) + + for i, line in enumerate(wrapped_message): + text = config.small_font.render(line, True, THEME_COLORS["text"]) + text_rect = text.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text, text_rect) + + draw_stylized_button(screen, _("button_yes"), rect_x + rect_width // 2 - 180, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.redownload_confirm_selection == 1) + draw_stylized_button(screen, _("button_no"), rect_x + rect_width // 2 + 20, rect_y + text_height + margin_top_bottom, 160, button_height, selected=config.redownload_confirm_selection == 0) + +# Popup avec compte Ă  rebours +def draw_popup(screen): + """Dessine un popup avec un message et un compte Ă  rebours.""" + screen.blit(OVERLAY, (0, 0)) + + popup_width = int(config.screen_width * 0.8) + line_height = config.small_font.get_height() + 10 + text_lines = config.popup_message.split('\n') + text_height = len(text_lines) * line_height + margin_top_bottom = 20 + popup_height = text_height + 2 * margin_top_bottom + line_height + popup_x = (config.screen_width - popup_width) // 2 + popup_y = (config.screen_height - popup_height) // 2 + + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (popup_x, popup_y, popup_width, popup_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (popup_x, popup_y, popup_width, popup_height), 2, border_radius=12) + + for i, line in enumerate(text_lines): + text_surface = config.small_font.render(line, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, popup_y + margin_top_bottom + i * line_height + line_height // 2)) + screen.blit(text_surface, text_rect) + + remaining_time = max(0, config.popup_timer // 1000) + countdown_text = _("popup_countdown").format(remaining_time, 's' if remaining_time != 1 else '') + countdown_surface = config.small_font.render(countdown_text, True, THEME_COLORS["text"]) + countdown_rect = countdown_surface.get_rect(center=(config.screen_width // 2, popup_y + margin_top_bottom + len(text_lines) * line_height + line_height // 2)) + screen.blit(countdown_surface, countdown_rect) \ No newline at end of file diff --git a/history.py b/history.py new file mode 100644 index 0000000..fd59398 --- /dev/null +++ b/history.py @@ -0,0 +1,85 @@ +import json +import os +import logging +import config +from datetime import datetime + +logger = logging.getLogger(__name__) + +# Chemin par dĂ©faut pour history.json +DEFAULT_HISTORY_PATH = os.path.join(config.SAVE_FOLDER, "history.json") + +def init_history(): + """Initialise le fichier history.json s'il n'existe pas.""" + history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH) + # VĂ©rifie si le fichier history.json existe, sinon le crĂ©e + if not os.path.exists(history_path): + try: + os.makedirs(os.path.dirname(history_path), exist_ok=True) + with open(history_path, "w", encoding='utf-8') as f: + json.dump([], f) # Initialise avec une liste vide + logger.info(f"Fichier d'historique créé : {history_path}") + except OSError as e: + logger.error(f"Erreur lors de la crĂ©ation du fichier d'historique : {e}") + else: + logger.info(f"Fichier d'historique trouvĂ© : {history_path}") + return history_path + +def load_history(): + """Charge l'historique depuis history.json.""" + history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH) + try: + if not os.path.exists(history_path): + logger.debug(f"Aucun fichier d'historique trouvĂ© Ă  {history_path}") + return [] + with open(history_path, "r", encoding='utf-8') as f: + history = json.load(f) + # Valider la structure : liste de dictionnaires avec 'platform', 'game_name', 'status' + for entry in history: + if not all(key in entry for key in ['platform', 'game_name', 'status']): + logger.warning(f"EntrĂ©e d'historique invalide : {entry}") + return [] + logger.debug(f"Historique chargĂ© depuis {history_path}, {len(history)} entrĂ©es") + return history + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.error(f"Erreur lors de la lecture de {history_path} : {e}") + return [] + +def save_history(history): + """Sauvegarde l'historique dans history.json.""" + history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH) + try: + os.makedirs(os.path.dirname(history_path), exist_ok=True) + with open(history_path, "w", encoding='utf-8') as f: + json.dump(history, f, indent=2, ensure_ascii=False) + logger.debug(f"Historique sauvegardĂ© dans {history_path}") + except Exception as e: + logger.error(f"Erreur lors de l'Ă©criture de {history_path} : {e}") + +def add_to_history(platform, game_name, status, url=None, progress=0, message=None, timestamp=None): + """Ajoute une entrĂ©e Ă  l'historique.""" + history = load_history() + entry = { + "platform": platform, + "game_name": game_name, + "status": status, + "url": url, + "progress": progress, + "timestamp": timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + if message: + entry["message"] = message + history.append(entry) + save_history(history) + logger.info(f"Ajout Ă  l'historique : platform={platform}, game_name={game_name}, status={status}, progress={progress}") + return entry + +def clear_history(): + """Vide l'historique.""" + history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH) + try: + with open(history_path, "w", encoding='utf-8') as f: + json.dump([], f) + logger.info(f"Historique vidĂ© : {history_path}") + except Exception as e: + logger.error(f"Erreur lors du vidage de {history_path} : {e}") \ No newline at end of file diff --git a/language.py b/language.py new file mode 100644 index 0000000..0cd5bb8 --- /dev/null +++ b/language.py @@ -0,0 +1,349 @@ +import os +import json +import pygame #type: ignore +import logging +import config + +logger = logging.getLogger(__name__) + +# Langue par dĂ©faut et variables globales +DEFAULT_LANGUAGE = "fr" +current_language = DEFAULT_LANGUAGE +translations = {} +show_language_selector_on_startup = False + +def load_language(lang_code=None): + """Charge les traductions pour la langue spĂ©cifiĂ©e ou la langue par dĂ©faut.""" + global current_language, translations + + if lang_code is None: + lang_code = DEFAULT_LANGUAGE + + lang_file = os.path.join(config.APP_FOLDER, "languages", f"{lang_code}.json") + + try: + if not os.path.exists(lang_file): + if lang_code != DEFAULT_LANGUAGE: + logger.warning(f"Fichier de langue {lang_code} non trouvĂ©, utilisation de la langue par dĂ©faut") + return load_language(DEFAULT_LANGUAGE) + else: + logger.error(f"Fichier de langue par dĂ©faut {lang_file} non trouvĂ©") + return False + + with open(lang_file, 'r', encoding='utf-8') as f: + translations = json.load(f) + + current_language = lang_code + logger.debug(f"Langue {lang_code} chargĂ©e avec succĂšs ({len(translations)} traductions)") + return True + + except Exception as e: + logger.error(f"Erreur lors du chargement de la langue {lang_code}: {str(e)}") + if lang_code != DEFAULT_LANGUAGE: + logger.warning(f"Tentative de chargement de la langue par dĂ©faut") + return load_language(DEFAULT_LANGUAGE) + return False + +def get_text(key, default=None): + """RĂ©cupĂšre la traduction correspondant Ă  la clĂ©.""" + if not translations: + load_language() + + if key in translations: + return translations[key] + + # Si la clĂ© n'existe pas, retourner la valeur par dĂ©faut ou la clĂ© elle-mĂȘme + if default is not None: + return default + + logger.warning(f"ClĂ© de traduction '{key}' non trouvĂ©e dans la langue {current_language}") + return key + +def get_available_languages(): + """RĂ©cupĂšre la liste des langues disponibles.""" + languages_dir = os.path.join(config.APP_FOLDER, "languages") + + if not os.path.exists(languages_dir): + logger.warning(f"Dossier des langues {languages_dir} non trouvĂ©") + return [] + + languages = [] + for file in os.listdir(languages_dir): + if file.endswith(".json"): + lang_code = os.path.splitext(file)[0] + languages.append(lang_code) + + return languages + +def set_language(lang_code): + """Change la langue courante et sauvegarde la prĂ©fĂ©rence.""" + if load_language(lang_code): + config.current_language = lang_code + save_language_preference(lang_code) + return True + return False + +def save_language_preference(lang_code): + """Sauvegarde la prĂ©fĂ©rence de langue dans un fichier.""" + try: + # S'assurer que le dossier existe + os.makedirs(os.path.dirname(config.LANGUAGE_CONFIG_PATH), exist_ok=True) + + # Sauvegarder la prĂ©fĂ©rence + with open(config.LANGUAGE_CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump({"language": lang_code}, f) + + logger.debug(f"PrĂ©fĂ©rence de langue sauvegardĂ©e: {lang_code}") + return True + except Exception as e: + logger.error(f"Erreur lors de la sauvegarde de la prĂ©fĂ©rence de langue: {str(e)}") + return False + +def load_language_preference(): + """Charge la prĂ©fĂ©rence de langue depuis le fichier.""" + global show_language_selector_on_startup + + try: + if not os.path.exists(config.LANGUAGE_CONFIG_PATH): + logger.info("Aucune prĂ©fĂ©rence de langue trouvĂ©e, utilisation du français par dĂ©faut") + # CrĂ©er le fichier avec le français par dĂ©faut + save_language_preference(DEFAULT_LANGUAGE) + return DEFAULT_LANGUAGE + + with open(config.LANGUAGE_CONFIG_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + lang_code = data.get("language", DEFAULT_LANGUAGE) + + logger.debug(f"PrĂ©fĂ©rence de langue chargĂ©e: {lang_code}") + return lang_code + except json.JSONDecodeError: + logger.warning("Fichier de prĂ©fĂ©rence de langue corrompu, utilisation du français par dĂ©faut") + # RecrĂ©er le fichier avec le français par dĂ©faut + save_language_preference(DEFAULT_LANGUAGE) + return DEFAULT_LANGUAGE + except Exception as e: + logger.error(f"Erreur lors du chargement de la prĂ©fĂ©rence de langue: {str(e)}") + # RecrĂ©er le fichier avec le français par dĂ©faut + save_language_preference(DEFAULT_LANGUAGE) + return DEFAULT_LANGUAGE + +def get_language_name(lang_code): + """Retourne le nom de la langue Ă  partir du code.""" + language_names = { + "fr": "Français", + "en": "English", + "es": "Español", + "de": "Deutsch", + "it": "Italiano", + "pt": "PortuguĂȘs", + "ja": "æ—„æœŹèȘž", + "zh": "äž­æ–‡", + "ru": "РуссĐșĐžĐč" + } + return language_names.get(lang_code, lang_code) + +def draw_language_selector(screen, selected_language_index): + """Affiche le sĂ©lecteur de langue.""" + from display import THEME_COLORS, OVERLAY + + # Obtenir les langues disponibles + available_languages = get_available_languages() + + if not available_languages: + logger.error("Aucune langue disponible") + return + + # Afficher l'overlay + screen.blit(OVERLAY, (0, 0)) + + # Titre + title_text = _("language_select_title") + title_surface = config.font.render(title_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, config.screen_height // 4)) + + # Fond du titre + title_bg_rect = title_rect.inflate(40, 20) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_bg_rect, border_radius=10) + pygame.draw.rect(screen, THEME_COLORS["border"], title_bg_rect, 2, border_radius=10) + screen.blit(title_surface, title_rect) + + # Options de langue + button_height = 60 + button_width = 300 + button_spacing = 20 + + total_height = len(available_languages) * (button_height + button_spacing) - button_spacing + start_y = (config.screen_height - total_height) // 2 + + for i, lang_code in enumerate(available_languages): + # Obtenir le nom de la langue + lang_name = get_language_name(lang_code) + + # Position du bouton + button_x = (config.screen_width - button_width) // 2 + button_y = start_y + i * (button_height + button_spacing) + + # Dessiner le bouton + button_color = THEME_COLORS["button_hover"] if i == selected_language_index else THEME_COLORS["button_idle"] + pygame.draw.rect(screen, button_color, (button_x, button_y, button_width, button_height), border_radius=10) + pygame.draw.rect(screen, THEME_COLORS["border"], (button_x, button_y, button_width, button_height), 2, border_radius=10) + + # Texte du bouton + text_surface = config.font.render(lang_name, True, THEME_COLORS["text"]) + text_rect = text_surface.get_rect(center=(button_x + button_width // 2, button_y + button_height // 2)) + screen.blit(text_surface, text_rect) + + # Instructions + instruction_text = _("language_select_instruction") + instruction_surface = config.small_font.render(instruction_text, True, THEME_COLORS["text"]) + instruction_rect = instruction_surface.get_rect(center=(config.screen_width // 2, config.screen_height - 50)) + screen.blit(instruction_surface, instruction_rect) + +def handle_language_menu_events(event, screen): + """GĂšre les Ă©vĂ©nements du menu de sĂ©lection de langue avec support clavier et manette.""" + available_languages = get_available_languages() + + if not available_languages: + logger.error("Aucune langue disponible") + config.menu_state = "platform" # Toujours revenir Ă  platform en cas d'erreur + config.needs_redraw = True + return + + # Navigation avec les touches du clavier + if event.type == pygame.KEYDOWN: + # Navigation vers le haut + if event.key == pygame.K_UP: + config.selected_language_index = (config.selected_language_index - 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le haut dans le sĂ©lecteur de langue: {config.selected_language_index}") + + # Navigation vers le bas + elif event.key == pygame.K_DOWN: + config.selected_language_index = (config.selected_language_index + 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le bas dans le sĂ©lecteur de langue: {config.selected_language_index}") + + # SĂ©lection de la langue + elif event.key == pygame.K_RETURN: + lang_code = available_languages[config.selected_language_index] + if set_language(lang_code): + logger.info(f"Langue changĂ©e pour {lang_code}") + config.current_language = lang_code + + # DĂ©terminer l'Ă©tat suivant en fonction du contexte + if config.previous_menu_state is None: + # Premier dĂ©marrage - passer Ă  l'Ă©tat loading pour charger les plateformes + config.menu_state = "loading" + logger.debug("Premier dĂ©marrage: passage Ă  l'Ă©tat loading aprĂšs sĂ©lection de la langue") + elif config.previous_menu_state == "pause_menu": + # Si on vient du menu pause, retourner au menu pause avec un message + config.menu_state = "restart_popup" + config.popup_message = _("language_changed").format(lang_code) + config.popup_timer = 2000 # 2 secondes + config.previous_menu_state = "platform" # Pour revenir Ă  l'Ă©cran principal aprĂšs le popup + logger.debug("Message de confirmation de changement de langue affichĂ©, retour au menu pause") + else: + # Autre cas, retourner Ă  l'Ă©tat prĂ©cĂ©dent avec un message + config.menu_state = "platform" # Toujours revenir Ă  platform pour Ă©viter les problĂšmes + logger.debug(f"Retour Ă  l'Ă©cran principal aprĂšs sĂ©lection de la langue") + else: + # Retour au menu pause en cas d'erreur + config.menu_state = "platform" # Toujours revenir Ă  platform en cas d'erreur + + config.needs_redraw = True + logger.debug(f"SĂ©lection de la langue: {lang_code}") + + # Annulation (seulement si on n'est pas au dĂ©marrage) + elif event.key == pygame.K_ESCAPE and config.previous_menu_state is not None: + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug("Annulation de la sĂ©lection de langue, retour au menu pause") + + # Support de la manette + elif event.type == pygame.JOYBUTTONDOWN: + # SĂ©lection avec le bouton A (gĂ©nĂ©ralement 0) + if event.button == 0: # Bouton A + lang_code = available_languages[config.selected_language_index] + if set_language(lang_code): + logger.info(f"Langue changĂ©e pour {lang_code} (manette)") + config.current_language = lang_code + + # DĂ©terminer l'Ă©tat suivant en fonction du contexte + if config.previous_menu_state is None: + # Premier dĂ©marrage - passer Ă  l'Ă©tat loading pour charger les plateformes + config.menu_state = "loading" + logger.debug("Premier dĂ©marrage: passage Ă  l'Ă©tat loading aprĂšs sĂ©lection de la langue (manette)") + else: + config.menu_state = "platform" + else: + config.menu_state = "platform" + config.needs_redraw = True + + # Annulation avec le bouton B (gĂ©nĂ©ralement 1) + elif event.button == 1 and config.previous_menu_state is not None: # Bouton B + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug("Annulation de la sĂ©lection de langue (manette), retour au menu pause") + + # Navigation avec le D-pad + elif event.type == pygame.JOYHATMOTION: + if event.value == (0, 1): # Haut + config.selected_language_index = (config.selected_language_index - 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le haut dans le sĂ©lecteur de langue (D-pad): {config.selected_language_index}") + elif event.value == (0, -1): # Bas + config.selected_language_index = (config.selected_language_index + 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le bas dans le sĂ©lecteur de langue (D-pad): {config.selected_language_index}") + + # Navigation avec les joysticks analogiques + elif event.type == pygame.JOYAXISMOTION: + # Joystick gauche vertical (gĂ©nĂ©ralement axe 1) + if event.axis == 1 and abs(event.value) > 0.5: + if event.value < -0.5: # Haut + config.selected_language_index = (config.selected_language_index - 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le haut dans le sĂ©lecteur de langue (joystick): {config.selected_language_index}") + elif event.value > 0.5: # Bas + config.selected_language_index = (config.selected_language_index + 1) % len(available_languages) + config.needs_redraw = True + logger.debug(f"Navigation vers le bas dans le sĂ©lecteur de langue (joystick): {config.selected_language_index}") + + +def update_valid_states(): + """Ajoute l'Ă©tat language_select Ă  la liste des Ă©tats valides.""" + from controls import VALID_STATES + if "language_select" not in VALID_STATES: + VALID_STATES.append("language_select") + logger.debug("État language_select ajoutĂ© aux Ă©tats valides") + +def initialize_language(): + """Initialise la langue au dĂ©marrage de l'application.""" + global show_language_selector_on_startup + + # VĂ©rifier si le fichier de prĂ©fĂ©rence de langue existe + language_file_exists = os.path.exists(config.LANGUAGE_CONFIG_PATH) + + # Si le fichier n'existe pas, crĂ©er un fichier avec le français par dĂ©faut + if not language_file_exists: + logger.info("Aucun fichier de prĂ©fĂ©rence de langue trouvĂ©, crĂ©ation avec le français par dĂ©faut") + save_language_preference(DEFAULT_LANGUAGE) + show_language_selector_on_startup = False # Ne pas afficher le sĂ©lecteur au dĂ©marrage + else: + # Le fichier existe, charger normalement + show_language_selector_on_startup = False # Ne jamais afficher le sĂ©lecteur au dĂ©marrage + + # Charger la prĂ©fĂ©rence de langue + lang_code = load_language_preference() + + # Charger la langue par dĂ©faut ou prĂ©fĂ©rĂ©e + if load_language(lang_code): + logger.info(f"Langue chargĂ©e au dĂ©marrage: {lang_code}") + else: + logger.warning(f"Impossible de charger la langue {lang_code}, utilisation de la langue par dĂ©faut") + load_language(DEFAULT_LANGUAGE) + + return True + +# Alias pour faciliter l'utilisation +_ = get_text \ No newline at end of file diff --git a/languages/en.json b/languages/en.json new file mode 100644 index 0000000..37abd22 --- /dev/null +++ b/languages/en.json @@ -0,0 +1,159 @@ +{ + "welcome_message": "Welcome to RGSX", + "disclaimer_line1": "It's dangerous to go alone, take all you need!", + "disclaimer_line2": "But only download games", + "disclaimer_line3": "that you already own!", + "disclaimer_line4": "RGSX is not responsible for downloaded content,", + "disclaimer_line5": "and does not host ROMs.", + + "loading_test_connection": "Testing connection...", + "loading_update_check": "Checking for updates... Please wait...", + "loading_download_data": "Downloading games and images...", + "loading_download_initial": "Downloading initial Data Folder...", + "loading_extract_initial": "Extracting initial Data Folder...", + "loading_systems": "Loading systems...", + "loading_progress": "Progress: {0}%", + + "error_no_internet": "No internet connection. Please check your network.", + "error_load_sources": "Failed to load sources.json", + "error_controls_mapping": "Failed to map controls", + "error_download_data": "Failed to download/extract Data Folder: {0}", + "error_api_key": "Please enter your API key (premium only) in the file {0}", + "error_api_key_extended": "Please enter your API key (premium only) in the file /userdata/saves/ports/rgsx/1fichierAPI.txt by opening it in a text editor and pasting your API key", + "error_invalid_download_data": "Invalid download data", + "error_delete_sources": "Error deleting sources.json file or folders", + "error_extension": "Unsupported extension or download error", + "error_no_download": "No pending download.", + + "platform_no_platform": "No platform", + "platform_page": "Page {0}/{1}", + + "game_no_games": "No games available", + "game_count": "{0} ({1} games)", + "game_filter": "Active filter: {0}", + "game_search": "Filter: {0}", + + "history_title": "Downloads ({0})", + "history_empty": "No downloads in history", + "history_column_system": "System", + "history_column_game": "Game name", + "history_column_status": "Status", + "history_status_downloading": "Downloading: {0}%", + "history_status_extracting": "Extracting: {0}%", + "history_status_completed": "Completed", + "history_status_error": "Error: {0}", + + "download_status": "{0}: {1}", + "download_progress": "{0}% {1} MB / {2} MB", + "download_canceled": "Download canceled by user.", + + "extension_warning_zip": "The file '{0}' is an archive and Batocera does not support archives for this system. Automatic extraction will occur after download, continue?", + "extension_warning_unsupported": "The file extension for '{0}' is not supported by Batocera according to the info.txt file. Do you want to continue?", + + "confirm_exit": "Exit application?", + "confirm_clear_history": "Clear history?", + "confirm_redownload_cache": "Redownload games cache?", + + "popup_redownload_success": "Games redownloaded successfully.\nPlease restart the application to see the changes.", + "popup_no_cache": "No cache found.\nPlease restart the application to load games.", + "popup_countdown": "This message will close in {0} second{1}", + + "language_select_title": "Language Selection", + "language_select_instruction": "Use arrow keys to navigate and Enter to select", + "language_changed": "Language changed to {0}", + + "menu_controls": "Controls", + "menu_remap_controls": "Remap controls", + "menu_history": "History", + "menu_language": "Language", + "menu_redownload_cache": "Redownload Games cache", + "menu_quit": "Quit", + + "button_yes": "Yes", + "button_no": "No", + "button_validate": "Validate", + + "controls_hold_message": "Hold for 3s for: '{0}'", + "controls_skip_message": "Press Esc to skip (PC only)", + "controls_waiting": "Waiting...", + "controls_hold": "Hold 3s", + + "controls_action_confirm": "Confirm", + "controls_action_cancel": "Cancel", + "controls_action_up": "Up", + "controls_action_down": "Down", + "controls_action_left": "Left", + "controls_action_right": "Right", + "controls_action_page_up": "Previous Page", + "controls_action_page_down": "Next Page", + "controls_action_progress": "Progress", + "controls_action_history": "History", + "controls_action_filter": "Filter", + "controls_action_delete": "Delete", + "controls_action_space": "Space", + "controls_action_start": "Menu", + + "controls_desc_confirm": "Validate (e.g. A, Enter)", + "controls_desc_cancel": "Cancel/Back (e.g. B, Backspace)", + "controls_desc_up": "Navigate up", + "controls_desc_down": "Navigate down", + "controls_desc_left": "Navigate left", + "controls_desc_right": "Navigate right", + "controls_desc_page_up": "Previous page/Fast scroll up (e.g. PageUp, LB)", + "controls_desc_page_down": "Next page/Fast scroll down (e.g. PageDown, RB)", + "controls_desc_progress": "View progress (e.g. X)", + "controls_desc_history": "Open history (e.g. H, Y)", + "controls_desc_filter": "Open filter (e.g. F, Select)", + "controls_desc_delete": "Delete character (e.g. LT, Delete)", + "controls_desc_space": "Add space (e.g. RT, Space)", + "controls_desc_start": "Open pause menu (e.g. Start, AltGr)", + + "footer_version": "RGSX v{0} - {1}: Options - {2}: History - {3}: Filter", + + "action_retry": "Retry", + "action_quit": "Quit", + "action_select": "Select", + "action_history": "History", + "action_progress": "Progress", + "action_download": "Download", + "action_filter": "Filter", + "action_cancel": "Cancel", + "action_back": "Back", + "action_navigate": "Navigate", + "action_page": "Page", + "action_cancel_download": "Cancel download", + "action_background": "Background", + "action_confirm": "Confirm", + "action_redownload": "Redownload", + "action_clear_history": "Clear history", + + "network_checking_updates": "Checking for updates...", + "network_update_available": "Update available: {0}", + "network_extracting_update": "Extracting update...", + "network_update_completed": "Update completed", + "network_update_success": "Update to {0} completed successfully. Please restart the application.", + "network_update_success_message": "Update completed successfully", + "network_no_update_available": "No update available", + "network_update_error": "Error during update: {0}", + "network_check_update_error": "Error checking for updates: {0}", + "network_extraction_failed": "Failed to extract update: {0}", + "network_extraction_partial": "Extraction successful, but some files were skipped due to errors: {0}", + "network_extraction_success": "Extraction successful", + "network_download_extract_ok": "Download and extraction successful of {0}", + "network_zip_extraction_error": "Error extracting ZIP {0}: {1}", + "network_permission_error": "No write permission in {0}", + "network_file_not_found": "File {0} does not exist", + "network_cannot_get_filename": "Cannot retrieve filename", + "network_cannot_get_download_url": "Cannot retrieve download URL", + "network_download_failed": "Download failed after {0} attempts", + "network_api_error": "API request error, the key may be incorrect: {0}", + "network_download_error": "Download error {0}: {1}", + "network_download_ok": "Download OK: {0}", + + "utils_extracted": "Extracted: {0}", + "utils_corrupt_zip": "Corrupted ZIP archive: {0}", + "utils_permission_denied": "Permission denied during extraction: {0}", + "utils_extraction_failed": "Extraction failed: {0}", + "utils_unrar_unavailable": "Unrar command not available", + "utils_rar_list_failed": "Failed to list RAR files: {0}" +} \ No newline at end of file diff --git a/languages/fr.json b/languages/fr.json new file mode 100644 index 0000000..41279a1 --- /dev/null +++ b/languages/fr.json @@ -0,0 +1,159 @@ +{ + "welcome_message": "Bienvenue dans RGSX", + "disclaimer_line1": "It's dangerous to go alone, take all you need!", + "disclaimer_line2": "Mais ne tĂ©lĂ©chargez que des jeux", + "disclaimer_line3": "dont vous possĂ©dez les originaux !", + "disclaimer_line4": "RGSX n'est pas responsable des contenus tĂ©lĂ©chargĂ©s,", + "disclaimer_line5": "et n'heberge pas de ROMs.", + + "loading_test_connection": "Test de connexion...", + "loading_update_check": "Verification Mise Ă  jour en cours... Patientez...", + "loading_download_data": "TĂ©lĂ©chargement des jeux et images ...", + "loading_download_initial": "TĂ©lĂ©chargement du Dossier Data initial...", + "loading_extract_initial": "Extraction du Dossier Data initial...", + "loading_systems": "Chargement des systĂšmes...", + "loading_progress": "Progression : {0}%", + + "error_no_internet": "Pas de connexion Internet. VĂ©rifiez votre rĂ©seau.", + "error_load_sources": "Échec du chargement de sources.json", + "error_controls_mapping": "Échec du mappage des contrĂŽles", + "error_download_data": "Échec du tĂ©lĂ©chargement/extraction du Dossier Data : {0}", + "error_api_key": "Attention il faut renseigner sa clĂ© API (premium only) dans le fichier {0}", + "error_api_key_extended": "Attention il faut renseigner sa clĂ© API (premium only) dans le fichier /userdata/saves/ports/rgsx/1fichierAPI.txt Ă  ouvrir dans un Ă©diteur de texte et coller la clĂ© API", + "error_invalid_download_data": "DonnĂ©es de tĂ©lĂ©chargement invalides", + "error_delete_sources": "Erreur lors de la suppression du fichier sources.json ou dossiers", + "error_extension": "Extension non supportĂ©e ou erreur de tĂ©lĂ©chargement", + "error_no_download": "Aucun tĂ©lĂ©chargement en attente.", + + "platform_no_platform": "Aucune plateforme", + "platform_page": "Page {0}/{1}", + + "game_no_games": "Aucun jeu disponible", + "game_count": "{0} ({1} jeux)", + "game_filter": "Filtre actif : {0}", + "game_search": "Filtrer : {0}", + + "history_title": "TĂ©lĂ©chargements ({0})", + "history_empty": "Aucun tĂ©lĂ©chargement dans l'historique", + "history_column_system": "SystĂšme", + "history_column_game": "Nom du jeu", + "history_column_status": "État", + "history_status_downloading": "TĂ©lĂ©chargement : {0}%", + "history_status_extracting": "Extraction : {0}%", + "history_status_completed": "TerminĂ©", + "history_status_error": "Erreur : {0}", + + "download_status": "{0} : {1}", + "download_progress": "{0}% {1} Mo / {2} Mo", + "download_canceled": "TĂ©lĂ©chargement annulĂ© par l'utilisateur.", + + "extension_warning_zip": "Le fichier '{0}' est une archive et Batocera ne prend pas en charge les archives pour ce systĂšme. L'extraction automatique du fichier aura lieu aprĂšs le tĂ©lĂ©chargement, continuer ?", + "extension_warning_unsupported": "L'extension du fichier '{0}' n'est pas supportĂ©e par Batocera d'aprĂšs le fichier info.txt. Voulez-vous continuer ?", + + "confirm_exit": "Quitter l'application ?", + "confirm_clear_history": "Vider l'historique ?", + "confirm_redownload_cache": "RetĂ©lĂ©charger le cache des jeux ?", + + "popup_redownload_success": "Redownload des jeux effectuĂ©.\nVeuillez redĂ©marrer l'application pour voir les changements.", + "popup_no_cache": "Aucun cache trouvĂ©.\nVeuillez redĂ©marrer l'application pour charger les jeux.", + "popup_countdown": "Ce message se fermera dans {0} seconde{1}", + + "language_select_title": "SĂ©lection de la langue", + "language_select_instruction": "Utilisez les flĂšches pour naviguer et EntrĂ©e pour sĂ©lectionner", + "language_changed": "Langue changĂ©e pour {0}", + + "menu_controls": "ContrĂŽles", + "menu_remap_controls": "Remapper les contrĂŽles", + "menu_history": "Historique", + "menu_language": "Langue", + "menu_redownload_cache": "RetĂ©lĂ©charger le cache des jeux", + "menu_quit": "Quitter", + + "button_yes": "Oui", + "button_no": "Non", + "button_validate": "Valider", + + "controls_hold_message": "Maintenez pendant 3s pour : '{0}'", + "controls_skip_message": "Appuyez sur Échap pour passer(Pc only)", + "controls_waiting": "Attente...", + "controls_hold": "Maintenez 3s", + + "controls_action_confirm": "Confirmer", + "controls_action_cancel": "Annuler", + "controls_action_up": "Haut", + "controls_action_down": "Bas", + "controls_action_left": "Gauche", + "controls_action_right": "Droite", + "controls_action_page_up": "Page PrĂ©cĂ©dente", + "controls_action_page_down": "Page Suivante", + "controls_action_progress": "Progression", + "controls_action_history": "Historique", + "controls_action_filter": "Filtrer", + "controls_action_delete": "Supprimer", + "controls_action_space": "Espace", + "controls_action_start": "Menu", + + "controls_desc_confirm": "Valider (ex: A, EntrĂ©e)", + "controls_desc_cancel": "Annuler/Retour (ex: B, RetourArriĂšre)", + "controls_desc_up": "Naviguer vers le haut", + "controls_desc_down": "Naviguer vers le bas", + "controls_desc_left": "Naviguer Ă  gauche", + "controls_desc_right": "Naviguer Ă  droite", + "controls_desc_page_up": "Page prĂ©cĂ©dente/DĂ©filement Rapide Haut (ex: PageUp, LB)", + "controls_desc_page_down": "Page suivante/DĂ©filement Rapide Bas (ex: PageDown, RB)", + "controls_desc_progress": "Voir progression (ex: X)", + "controls_desc_history": "Ouvrir l'historique (ex: H, Y)", + "controls_desc_filter": "Ouvrir filtre (ex: F, Select)", + "controls_desc_delete": "Supprimer caractĂšre (ex: LT, Suppr)", + "controls_desc_space": "Ajouter espace (ex: RT, Espace)", + "controls_desc_start": "Ouvrir le menu pause (ex: Start, AltGr)", + + "footer_version": "RGSX v{0} - {1} : Options - {2}: Historique - {3} : Filtrer", + + "action_retry": "Retenter", + "action_quit": "Quitter", + "action_select": "SĂ©lectionner", + "action_history": "Historique", + "action_progress": "Progression", + "action_download": "TĂ©lĂ©charger", + "action_filter": "Filtrer", + "action_cancel": "Annuler", + "action_back": "Retour", + "action_navigate": "Naviguer", + "action_page": "Page", + "action_cancel_download": "Annuler le tĂ©lĂ©chargement", + "action_background": "ArriĂšre plan", + "action_confirm": "Confirmer", + "action_redownload": "RetĂ©lĂ©charger", + "action_clear_history": "Vider l'historique", + + "network_checking_updates": "VĂ©rification des mises Ă  jour...", + "network_update_available": "Mise Ă  jour disponible : {0}", + "network_extracting_update": "Extraction de la mise Ă  jour...", + "network_update_completed": "Mise Ă  jour terminĂ©e", + "network_update_success": "Mise Ă  jour vers {0} terminĂ©e avec succĂšs. Veuillez redĂ©marrer l'application.", + "network_update_success_message": "Mise Ă  jour terminĂ©e avec succĂšs", + "network_no_update_available": "Aucune mise Ă  jour disponible", + "network_update_error": "Erreur lors de la mise Ă  jour : {0}", + "network_download_extract_ok": "TĂ©lĂ©chargement et extraction rĂ©ussi de {0}", + "network_check_update_error": "Erreur lors de la vĂ©rification des mises Ă  jour : {0}", + "network_extraction_failed": "Échec de l'extraction de la mise Ă  jour : {0}", + "network_extraction_partial": "Extraction rĂ©ussie, mais certains fichiers ont Ă©tĂ© ignorĂ©s en raison d'erreurs : {0}", + "network_extraction_success": "Extraction rĂ©ussie", + "network_zip_extraction_error": "Erreur lors de l'extraction du ZIP {0}: {1}", + "network_permission_error": "Pas de permission d'Ă©criture dans {0}", + "network_file_not_found": "Le fichier {0} n'existe pas", + "network_cannot_get_filename": "Impossible de rĂ©cupĂ©rer le nom du fichier", + "network_cannot_get_download_url": "Impossible de rĂ©cupĂ©rer l'URL de tĂ©lĂ©chargement", + "network_download_failed": "Échec du tĂ©lĂ©chargement aprĂšs {0} tentatives", + "network_api_error": "Erreur lors de la requĂȘte API, la clĂ© est peut-ĂȘtre incorrecte: {0}", + "network_download_error": "Erreur tĂ©lĂ©chargement {0}: {1}", + "network_download_ok": "TĂ©lĂ©chargement ok : {0}", + + "utils_extracted": "Extracted: {0}", + "utils_corrupt_zip": "Archive ZIP corrompue: {0}", + "utils_permission_denied": "Permission refusĂ©e lors de l'extraction: {0}", + "utils_extraction_failed": "Échec de l'extraction: {0}", + "utils_unrar_unavailable": "Commande unrar non disponible", + "utils_rar_list_failed": "Échec de la liste des fichiers RAR: {0}" +} \ No newline at end of file diff --git a/network.py b/network.py new file mode 100644 index 0000000..91288d0 --- /dev/null +++ b/network.py @@ -0,0 +1,595 @@ +import requests +import subprocess +import os +import threading +import pygame # type: ignore +import zipfile +import asyncio +import config +from config import OTA_VERSION_ENDPOINT,APP_FOLDER, UPDATE_FOLDER, OTA_UPDATE_ZIP +from utils import sanitize_filename, extract_zip, extract_rar, load_api_key_1fichier +from history import save_history +import logging +import queue +import time +import os +from language import _ # Import de la fonction de traduction + + +logger = logging.getLogger(__name__) + + +cache = {} +CACHE_TTL = 3600 # 1 heure + +def test_internet(): + logger.debug("Test de connexion Internet") + try: + result = subprocess.run(['ping', '-c', '4', '8.8.8.8'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + logger.debug("Connexion Internet OK") + return True + else: + logger.debug("Échec ping 8.8.8.8") + return False + except Exception as e: + logger.debug(f"Erreur test Internet: {str(e)}") + return False + + +async def check_for_updates(): + try: + logger.debug("VĂ©rification de la version disponible sur le serveur") + config.current_loading_system = _("network_checking_updates") + config.loading_progress = 5.0 + config.needs_redraw = True + response = requests.get(OTA_VERSION_ENDPOINT, timeout=5) + response.raise_for_status() + if response.headers.get("content-type") != "application/json": + raise ValueError(f"Le fichier version.json n'est pas un JSON valide (type de contenu : {response.headers.get('content-type')})") + version_data = response.json() + latest_version = version_data.get("version") + logger.debug(f"Version distante : {latest_version}, version locale : {config.app_version}") + UPDATE_ZIP = OTA_UPDATE_ZIP.replace("RGSX.zip", f"RGSX_v{latest_version}.zip") + logger.debug(f"URL de mise Ă  jour : {UPDATE_ZIP}") + if latest_version != config.app_version: + config.current_loading_system = _("network_update_available").format(latest_version) + config.loading_progress = 10.0 + config.needs_redraw = True + logger.debug(f"TĂ©lĂ©chargement du ZIP de mise Ă  jour : {UPDATE_ZIP}") + + # CrĂ©er le dossier UPDATE_FOLDER s'il n'existe pas + os.makedirs(UPDATE_FOLDER, exist_ok=True) + update_zip_path = os.path.join(UPDATE_FOLDER, "RGSX.zip") + logger.debug(f"TĂ©lĂ©chargement de {UPDATE_ZIP} vers {update_zip_path}") + + # TĂ©lĂ©charger le ZIP + with requests.get(UPDATE_ZIP, stream=True, timeout=10) as r: + r.raise_for_status() + total_size = int(r.headers.get('content-length', 0)) + downloaded = 0 + with open(update_zip_path, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + config.loading_progress = 10.0 + (40.0 * downloaded / total_size) if total_size > 0 else 10.0 + config.needs_redraw = True + await asyncio.sleep(0) + logger.debug(f"ZIP tĂ©lĂ©chargĂ© : {update_zip_path}") + + # Extraire le contenu du ZIP dans APP_FOLDER + config.current_loading_system = _("network_extracting_update") + config.loading_progress = 60.0 + config.needs_redraw = True + success, message = extract_update(update_zip_path, APP_FOLDER, UPDATE_ZIP) + if not success: + logger.error(f"Échec de l'extraction : {message}") + return False, _("network_extraction_failed").format(message) + + # Supprimer le fichier ZIP aprĂšs extraction + if os.path.exists(update_zip_path): + os.remove(update_zip_path) + logger.debug(f"Fichier ZIP {update_zip_path} supprimĂ©") + + config.current_loading_system = _("network_update_completed") + config.loading_progress = 100.0 + config.needs_redraw = True + logger.debug("Mise Ă  jour terminĂ©e avec succĂšs") + + # Configurer la popup pour afficher le message de succĂšs + config.menu_state = "update_result" + config.update_result_message = _("network_update_success").format(latest_version) + config.update_result_error = False + config.update_result_start_time = pygame.time.get_ticks() + config.needs_redraw = True + logger.debug(f"Affichage de la popup de mise Ă  jour rĂ©ussie") + + return True, _("network_update_success_message") + else: + logger.debug("Aucune mise Ă  jour disponible") + return True, _("network_no_update_available") + + except Exception as e: + logger.error(f"Erreur OTA : {str(e)}") + config.menu_state = "update_result" + config.update_result_message = _("network_update_error").format(str(e)) + config.update_result_error = True + config.update_result_start_time = pygame.time.get_ticks() + config.needs_redraw = True + return False, _("network_check_update_error").format(str(e)) + +def extract_update(zip_path, dest_dir, source_url): + try: + # VĂ©rifier et ajuster les permissions du rĂ©pertoire de destination + os.makedirs(dest_dir, exist_ok=True) + try: + subprocess.run(["chmod", "-R", "u+rw", dest_dir], check=True) + logger.debug(f"Permissions ajustĂ©es pour {dest_dir}") + except subprocess.CalledProcessError as e: + logger.warning(f"Impossible d'ajuster les permissions pour {dest_dir}: {str(e)}") + + # Extraire le ZIP + skipped_files = [] + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + for file_info in zip_ref.infolist(): + try: + zip_ref.extract(file_info, dest_dir) + except PermissionError as e: + logger.warning(f"Impossible d'extraire {file_info.filename}: {str(e)}") + skipped_files.append(file_info.filename) + except Exception as e: + logger.warning(f"Erreur lors de l'extraction de {file_info.filename}: {str(e)}") + skipped_files.append(file_info.filename) + + if skipped_files: + message = _("network_extraction_partial").format(', '.join(skipped_files)) + logger.warning(message) + return True, message # ConsidĂ©rer comme succĂšs si certains fichiers sont extraits + return True, _("network_extraction_success") + + except Exception as e: + logger.error(f"Erreur critique lors de l'extraction du ZIP {source_url}: {str(e)}") + return False, _("network_zip_extraction_error").format(source_url, str(e)) + +# File d'attente pour la progression +import queue +progress_queue = queue.Queue() + + + +async def download_rom(url, platform, game_name, is_zip_non_supported=False, task_id=None): + logger.debug(f"DĂ©but tĂ©lĂ©chargement: {game_name} depuis {url}, is_zip_non_supported={is_zip_non_supported}, task_id={task_id}") + result = [None, None] + + # Vider la file d'attente avant de commencer + while not progress_queue.empty(): + try: + progress_queue.get_nowait() + logger.debug(f"File progress_queue vidĂ©e pour {game_name}") + except queue.Empty: + break + + def download_thread(): + logger.debug(f"Thread tĂ©lĂ©chargement dĂ©marrĂ© pour {url}, task_id={task_id}") + try: + dest_dir = None + for platform_dict in config.platform_dicts: + if platform_dict["platform"] == platform: + dest_dir = platform_dict.get("folder") + break + if not dest_dir: + dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform.lower().replace(" ", "")) + + os.makedirs(dest_dir, exist_ok=True) + if not os.access(dest_dir, os.W_OK): + raise PermissionError(f"Pas de permission d'Ă©criture dans {dest_dir}") + + sanitized_name = sanitize_filename(game_name) + dest_path = os.path.join(dest_dir, f"{sanitized_name}") + logger.debug(f"Chemin destination: {dest_path}") + + headers = {'User-Agent': 'Mozilla/5.0'} + response = requests.get(url, stream=True, headers=headers, timeout=30) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + logger.debug(f"Taille totale: {total_size} octets") + + # Initialiser la progression avec task_id + progress_queue.put((task_id, 0, total_size)) + logger.debug(f"Progression initiale envoyĂ©e: 0% pour {game_name}, task_id={task_id}") + + downloaded = 0 + chunk_size = 4096 + last_update_time = time.time() + update_interval = 0.5 # Mettre Ă  jour toutes les 0,5 secondes + with open(dest_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + size_received = len(chunk) + f.write(chunk) + downloaded += size_received + current_time = time.time() + if current_time - last_update_time >= update_interval: + # Calculer le pourcentage correctement et le limiter entre 0 et 100 + progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0 + progress_percent = max(0, min(100, progress_percent)) + progress_queue.put((task_id, downloaded, total_size)) + last_update_time = current_time + else: + logger.debug("Chunk vide reçu") + + os.chmod(dest_path, 0o644) + logger.debug(f"TĂ©lĂ©chargement terminĂ©: {dest_path}") + + # VĂ©rifier si l'extraction est nĂ©cessaire pour les archives non supportĂ©es + if is_zip_non_supported: + logger.debug(f"Extraction automatique nĂ©cessaire pour {dest_path}") + extension = os.path.splitext(dest_path)[1].lower() + if extension == ".zip": + try: + # Mettre Ă  jour le statut avant l'extraction + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["status"] = "Extracting" + entry["progress"] = 0 + entry["message"] = "PrĂ©paration de l'extraction..." + save_history(config.history) + config.needs_redraw = True + break + + success, msg = extract_zip(dest_path, dest_dir, url) + if success: + logger.debug(f"Extraction ZIP rĂ©ussie: {msg}") + result[0] = True + result[1] = _("network_download_extract_ok").format(game_name) + else: + logger.error(f"Erreur extraction ZIP: {msg}") + result[0] = False + result[1] = _("network_extraction_failed").format(msg) + except Exception as e: + logger.error(f"Exception lors de l'extraction: {str(e)}") + result[0] = False + result[1] = f"Erreur tĂ©lĂ©chargement {game_name}: {str(e)}" + elif extension == ".rar": + try: + success, msg = extract_rar(dest_path, dest_dir, url) + if success: + logger.debug(f"Extraction RAR rĂ©ussie: {msg}") + result[0] = True + result[1] = _("network_download_extract_ok").format(game_name) + else: + logger.error(f"Erreur extraction RAR: {msg}") + result[0] = False + result[1] = _("network_extraction_failed").format(msg) + except Exception as e: + logger.error(f"Exception lors de l'extraction RAR: {str(e)}") + result[0] = False + result[1] = f"Erreur extraction RAR {game_name}: {str(e)}" + else: + logger.warning(f"Type d'archive non supportĂ©: {extension}") + result[0] = True + result[1] = _("network_download_ok").format(game_name) + else: + result[0] = True + result[1] = _("network_download_ok").format(game_name) + except Exception as e: + logger.error(f"Erreur tĂ©lĂ©chargement {url}: {str(e)}") + result[0] = False + result[1] = _("network_download_error").format(game_name, str(e)) + finally: + logger.debug(f"Thread tĂ©lĂ©chargement terminĂ© pour {url}, task_id={task_id}") + progress_queue.put((task_id, result[0], result[1])) + logger.debug(f"Final result sent to queue: success={result[0]}, message={result[1]}, task_id={task_id}") + + thread = threading.Thread(target=download_thread) + thread.start() + + # Boucle principale pour mettre Ă  jour la progression + while thread.is_alive(): + try: + while not progress_queue.empty(): + data = progress_queue.get() + logger.debug(f"Progress queue data received: {data}") + if len(data) != 3 or data[0] != task_id: # Ignorer les donnĂ©es d'une autre tĂąche + logger.debug(f"Ignoring queue data for task_id={data[0]}, expected={task_id}") + continue + if isinstance(data[1], bool): # Fin du tĂ©lĂ©chargement + success, message = data[1], data[2] + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement", "Extracting"]: + entry["status"] = "Download_OK" if success else "Erreur" + entry["progress"] = 100 if success else 0 + # Utiliser une variable intermĂ©diaire pour stocker le message + message_text = message + entry["message"] = message_text + save_history(config.history) + config.needs_redraw = True + logger.debug(f"Final update in history: status={entry['status']}, progress={entry['progress']}%, message={message}, task_id={task_id}") + break + else: + downloaded, total_size = data[1], data[2] + # Calculer le pourcentage correctement et le limiter entre 0 et 100 + progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0 + progress_percent = max(0, min(100, progress_percent)) + + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["progress"] = progress_percent + entry["status"] = "TĂ©lĂ©chargement" + entry["downloaded_size"] = downloaded + entry["total_size"] = total_size + config.needs_redraw = True + break + await asyncio.sleep(0.2) + except Exception as e: + logger.error(f"Erreur mise Ă  jour progression: {str(e)}") + + thread.join() + logger.debug(f"Thread joined for {url}, task_id={task_id}") + return result[0], result[1] + +async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=False, task_id=None): + load_api_key_1fichier() + logger.debug(f"DĂ©but tĂ©lĂ©chargement 1fichier: {game_name} depuis {url}, is_zip_non_supported={is_zip_non_supported}, task_id={task_id}") + result = [None, None] + + # Vider la file d'attente avant de commencer + while not progress_queue.empty(): + try: + progress_queue.get_nowait() + logger.debug(f"File progress_queue vidĂ©e pour {game_name}") + except queue.Empty: + break + + def download_thread(): + logger.debug(f"Thread tĂ©lĂ©chargement 1fichier dĂ©marrĂ© pour {url}, task_id={task_id}") + try: + link = url.split('&af=')[0] + dest_dir = None + for platform_dict in config.platform_dicts: + if platform_dict["platform"] == platform: + dest_dir = platform_dict.get("folder") + break + if not dest_dir: + logger.warning(f"Aucun dossier 'folder' trouvĂ© pour la plateforme {platform}") + dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform) + + logger.debug(f"VĂ©rification rĂ©pertoire destination: {dest_dir}") + os.makedirs(dest_dir, exist_ok=True) + if not os.access(dest_dir, os.W_OK): + raise PermissionError(f"Pas de permission d'Ă©criture dans {dest_dir}") + + headers = { + "Authorization": f"Bearer {config.API_KEY_1FICHIER}", + "Content-Type": "application/json" + } + payload = { + "url": link, + "pretty": 1 + } + + logger.debug(f"Envoi requĂȘte POST Ă  https://api.1fichier.com/v1/file/info.cgi pour {url}") + response = requests.post("https://api.1fichier.com/v1/file/info.cgi", headers=headers, json=payload, timeout=30) + logger.debug(f"RĂ©ponse reçue, status: {response.status_code}") + response.raise_for_status() + file_info = response.json() + + if "error" in file_info and file_info["error"] == "Resource not found": + logger.error(f"Le fichier {game_name} n'existe pas sur 1fichier") + result[0] = False + result[1] = _("network_file_not_found").format(game_name) + return + + filename = file_info.get("filename", "").strip() + if not filename: + logger.error("Impossible de rĂ©cupĂ©rer le nom du fichier") + result[0] = False + result[1] = _("network_cannot_get_filename") + return + + sanitized_filename = sanitize_filename(filename) + dest_path = os.path.join(dest_dir, sanitized_filename) + logger.debug(f"Chemin destination: {dest_path}") + + logger.debug(f"Envoi requĂȘte POST Ă  https://api.1fichier.com/v1/download/get_token.cgi pour {url}") + response = requests.post("https://api.1fichier.com/v1/download/get_token.cgi", headers=headers, json=payload, timeout=30) + logger.debug(f"RĂ©ponse reçue, status: {response.status_code}") + response.raise_for_status() + download_info = response.json() + + final_url = download_info.get("url") + if not final_url: + logger.error("Impossible de rĂ©cupĂ©rer l'URL de tĂ©lĂ©chargement") + result[0] = False + result[1] = _("network_cannot_get_download_url") + return + + lock = threading.Lock() + retries = 10 + retry_delay = 10 + # Initialiser la progression avec task_id + progress_queue.put((task_id, 0, 0)) # Taille initiale inconnue + logger.debug(f"Progression initiale envoyĂ©e: 0% pour {game_name}, task_id={task_id}") + for attempt in range(retries): + try: + logger.debug(f"Tentative {attempt + 1} : Envoi requĂȘte GET Ă  {final_url}") + with requests.get(final_url, stream=True, headers={'User-Agent': 'Mozilla/5.0'}, timeout=30) as response: + logger.debug(f"RĂ©ponse reçue, status: {response.status_code}") + response.raise_for_status() + total_size = int(response.headers.get('content-length', 0)) + logger.debug(f"Taille totale: {total_size} octets") + with lock: + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] == "downloading": + entry["total_size"] = total_size + config.needs_redraw = True + break + progress_queue.put((task_id, 0, total_size)) # Mettre Ă  jour la taille totale + + downloaded = 0 + chunk_size = 8192 + last_update_time = time.time() + update_interval = 0.5 # Mettre Ă  jour toutes les 0,5 secondes + with open(dest_path, 'wb') as f: + logger.debug(f"Ouverture fichier: {dest_path}") + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + f.write(chunk) + downloaded += len(chunk) + current_time = time.time() + if current_time - last_update_time >= update_interval: + with lock: + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] == "downloading": + # Calculer le pourcentage correctement et le limiter entre 0 et 100 + progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0 + progress_percent = max(0, min(100, progress_percent)) + entry["progress"] = progress_percent + entry["status"] = "TĂ©lĂ©chargement" + entry["downloaded_size"] = downloaded + entry["total_size"] = total_size + config.needs_redraw = True + logger.debug(f"Progression mise Ă  jour: {entry['progress']:.1f}% pour {game_name}") + break + progress_queue.put((task_id, downloaded, total_size)) + last_update_time = current_time + + if is_zip_non_supported: + with lock: + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] == "TĂ©lĂ©chargement": + entry["progress"] = 0 + entry["status"] = "Extracting" + config.needs_redraw = True + break + extension = os.path.splitext(dest_path)[1].lower() + if extension == ".zip": + try: + success, msg = extract_zip(dest_path, dest_dir, url) + if success: + logger.debug(f"Extraction ZIP rĂ©ussie: {msg}") + result[0] = True + result[1] = _("network_download_extract_ok").format(game_name) + else: + logger.error(f"Erreur extraction ZIP: {msg}") + result[0] = False + result[1] = _("network_extraction_failed").format(msg) + except Exception as e: + logger.error(f"Exception lors de l'extraction: {str(e)}") + result[0] = False + result[1] = f"Erreur tĂ©lĂ©chargement {game_name}: {str(e)}" + elif extension == ".rar": + try: + success, msg = extract_rar(dest_path, dest_dir, url) + if success: + logger.debug(f"Extraction RAR rĂ©ussie: {msg}") + result[0] = True + result[1] = _("network_download_extract_ok").format(game_name) + else: + logger.error(f"Erreur extraction RAR: {msg}") + result[0] = False + result[1] = _("network_extraction_failed").format(msg) + except Exception as e: + logger.error(f"Exception lors de l'extraction RAR: {str(e)}") + result[0] = False + result[1] = f"Erreur extraction RAR {game_name}: {str(e)}" + else: + logger.warning(f"Type d'archive non supportĂ©: {extension}") + result[0] = True + result[1] = _("network_download_ok").format(game_name) + else: + os.chmod(dest_path, 0o644) + logger.debug(f"TĂ©lĂ©chargement terminĂ©: {dest_path}") + result[0] = True + result[1] = _("network_download_ok").format(game_name) + return + + except requests.exceptions.RequestException as e: + logger.error(f"Tentative {attempt + 1} Ă©chouĂ©e : {e}") + if attempt < retries - 1: + time.sleep(retry_delay) + else: + logger.error("Nombre maximum de tentatives atteint") + result[0] = False + result[1] = _("network_download_failed").format(retries) + return + + except requests.exceptions.RequestException as e: + logger.error(f"Erreur API 1fichier : {e}") + result[0] = False + result[1] = _("network_api_error").format(str(e)) + + finally: + logger.debug(f"Thread tĂ©lĂ©chargement 1fichier terminĂ© pour {url}, task_id={task_id}") + progress_queue.put((task_id, result[0], result[1])) + logger.debug(f"Final result sent to queue: success={result[0]}, message={result[1]}, task_id={task_id}") + + thread = threading.Thread(target=download_thread) + logger.debug(f"DĂ©marrage thread pour {url}, task_id={task_id}") + thread.start() + + # Boucle principale pour mettre Ă  jour la progression + while thread.is_alive(): + try: + while not progress_queue.empty(): + data = progress_queue.get() + logger.debug(f"Progress queue data received: {data}") + if len(data) != 3 or data[0] != task_id: # Ignorer les donnĂ©es d'une autre tĂąche + logger.debug(f"Ignoring queue data for task_id={data[0]}, expected={task_id}") + continue + if isinstance(data[1], bool): # Fin du tĂ©lĂ©chargement + success, message = data[1], data[2] + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement", "Extracting"]: + entry["status"] = "Download_OK" if success else "Erreur" + entry["progress"] = 100 if success else 0 + # Utiliser une variable intermĂ©diaire pour stocker le message + message_text = message + entry["message"] = message_text + save_history(config.history) + config.needs_redraw = True + logger.debug(f"Final update in history: status={entry['status']}, progress={entry['progress']}%, message={message}, task_id={task_id}") + break + else: + downloaded, total_size = data[1], data[2] + # Calculer le pourcentage correctement et le limiter entre 0 et 100 + progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0 + progress_percent = max(0, min(100, progress_percent)) + + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "TĂ©lĂ©chargement"]: + entry["progress"] = progress_percent + entry["status"] = "TĂ©lĂ©chargement" + entry["downloaded_size"] = downloaded + entry["total_size"] = total_size + config.needs_redraw = True + break + await asyncio.sleep(0.2) + except Exception as e: + logger.error(f"Erreur mise Ă  jour progression: {str(e)}") + + thread.join() + logger.debug(f"Thread joined for {url}, task_id={task_id}") + return result[0], result[1] + + +def is_1fichier_url(url): + """DĂ©tecte si l'URL est un lien 1fichier.""" + return "1fichier.com" in url \ No newline at end of file diff --git a/rom_extensions.json b/rom_extensions.json new file mode 100644 index 0000000..bdf3fb8 --- /dev/null +++ b/rom_extensions.json @@ -0,0 +1,2317 @@ +[ + { + "system": "3DO INTERACTIVE MULTIPLAYER", + "folder": "/userdata/roms/3do", + "extensions": [ + ".iso", + ".chd", + ".cue" + ] + }, + { + "system": "3DS", + "folder": "/userdata/roms/3ds", + "extensions": [ + ".3ds", + ".3dsx", + ".cxi", + ".axf", + ".elf", + ".app", + ".squashfs" + ] + }, + { + "system": "ABUSE", + "folder": "/userdata/roms/abuse", + "extensions": [ + ".game" + ] + }, + { + "system": "ADAM", + "folder": "/userdata/roms/adam", + "extensions": [ + ".wav", + ".ddp", + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".rom", + ".col", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "ADVENTURE VISION", + "folder": "/userdata/roms/advision", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "AMIGA AGA", + "folder": "/userdata/roms/amiga1200", + "extensions": [ + ".adf", + ".uae", + ".ipf", + ".dms", + ".dmz", + ".adz", + ".lha", + ".hdf", + ".exe", + ".m3u", + ".zip", + ".raw", + ".scp" + ] + }, + { + "system": "AMIGA OCS/ECS", + "folder": "/userdata/roms/amiga500", + "extensions": [ + ".adf", + ".uae", + ".ipf", + ".dms", + ".dmz", + ".adz", + ".lha", + ".hdf", + ".exe", + ".m3u", + ".zip", + ".raw", + ".scp" + ] + }, + { + "system": "AMIGA CD32", + "folder": "/userdata/roms/amigacd32", + "extensions": [ + ".bin", + ".cue", + ".iso", + ".chd" + ] + }, + { + "system": "AMIGA CDTV", + "folder": "/userdata/roms/amigacdtv", + "extensions": [ + ".bin", + ".cue", + ".iso", + ".chd", + ".m3u" + ] + }, + { + "system": "AMSTRAD CPC", + "folder": "/userdata/roms/amstradcpc", + "extensions": [ + ".dsk", + ".sna", + ".tap", + ".cdt", + ".voc", + ".m3u", + ".zip", + ".7z" + ] + }, + { + "system": "M-1000", + "folder": "/userdata/roms/apfm1000", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "APPLE II", + "folder": "/userdata/roms/apple2", + "extensions": [ + ".nib", + ".do", + ".po", + ".dsk", + ".mfi", + ".dfi", + ".rti", + ".edd", + ".woz", + ".wav", + ".zip", + ".7z", + ".chd", + ".hdv", + ".2mg" + ] + }, + { + "system": "APPLE IIGS", + "folder": "/userdata/roms/apple2gs", + "extensions": [ + ".2mg", + ".do", + ".nib", + ".po", + ".dsk", + ".mfi", + ".dfi", + ".rti", + ".edd", + ".woz", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqui", + ".ima", + ".img", + ".ufi", + ".360", + ".ipf", + ".dc42", + ".zip", + ".7z" + ] + }, + { + "system": "ARCADIA 2001", + "folder": "/userdata/roms/arcadia", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "ARCHIMEDES", + "folder": "/userdata/roms/archimedes", + "extensions": [ + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".ima", + ".img", + ".ufi", + ".360", + ".ipf", + ".adf", + ".apd", + ".jfd", + ".ads", + ".adm", + ".adl", + ".ssd", + ".bbc", + ".dsd", + ".st", + ".msa", + ".chd", + ".zip", + ".7z" + ] + }, + { + "system": "ARDUBOY", + "folder": "/userdata/roms/arduboy", + "extensions": [ + ".hex", + ".zip", + ".7z" + ] + }, + { + "system": "ASTROCADE", + "folder": "/userdata/roms/astrocde", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "ATARI 2600", + "folder": "/userdata/roms/atari2600", + "extensions": [ + ".a26", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "ATARI 5200", + "folder": "/userdata/roms/atari5200", + "extensions": [ + ".rom", + ".xfd", + ".atr", + ".atx", + ".cdm", + ".cas", + ".car", + ".bin", + ".a52", + ".xex", + ".zip", + ".7z" + ] + }, + { + "system": "ATARI 7800", + "folder": "/userdata/roms/atari7800", + "extensions": [ + ".a78", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "ATARI 800", + "folder": "/userdata/roms/atari800", + "extensions": [ + ".rom", + ".xfd", + ".atr", + ".atx", + ".cdm", + ".cas", + ".car", + ".bin", + ".a52", + ".xex", + ".zip", + ".7z", + ".m3u" + ] + }, + { + "system": "ATARI ST", + "folder": "/userdata/roms/atarist", + "extensions": [ + ".st", + ".msa", + ".stx", + ".dim", + ".ipf", + ".m3u", + ".zip", + ".7z", + ".hd", + ".gemdos" + ] + }, + { + "system": "ATOM", + "folder": "/userdata/roms/atom", + "extensions": [ + ".wav", + ".tap", + ".csw", + ".uef", + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".40t", + ".atm", + ".bin", + ".rom", + ".zip", + ".7z" + ] + }, + { + "system": "ATOMISWAVE", + "folder": "/userdata/roms/atomiswave", + "extensions": [ + ".lst", + ".bin", + ".dat", + ".zip", + ".7z" + ] + }, + { + "system": "BBC MICRO", + "folder": "/userdata/roms/bbc", + "extensions": [ + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".ima", + ".img", + ".ufi", + ".360", + ".ipf", + ".ssd", + ".bbc", + ".dsd", + ".adf", + ".ads", + ".adm", + ".adl", + ".fsd", + ".wav", + ".tap", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "COMMODORE 128", + "folder": "/userdata/roms/c128", + "extensions": [ + ".d64", + ".d81", + ".prg", + ".lnx", + ".m3u", + ".zip", + ".7z" + ] + }, + { + "system": "COMMODORE VIC-20", + "folder": "/userdata/roms/c20", + "extensions": [ + ".20", + ".40", + ".60", + ".rom", + ".a0", + ".b0", + ".crt", + ".d64", + ".d81", + ".prg", + ".tap", + ".t64", + ".m3u", + ".zip", + ".7z" + ] + }, + { + "system": "COMMODORE 64", + "folder": "/userdata/roms/c64", + "extensions": [ + ".d64", + ".d71", + ".d81", + ".crt", + ".prg", + ".tap", + ".t64", + ".m3u", + ".zip", + ".7z", + ".nib", + ".g64" + ] + }, + { + "system": "CAMPUTERS LYNX", + "folder": "/userdata/roms/camplynx", + "extensions": [ + ".wav", + ".tap", + ".ldf", + ".zip", + ".7z" + ] + }, + { + "system": "CANNONBALL", + "folder": "/userdata/roms/cannonball", + "extensions": [ + ".cannonball" + ] + }, + { + "system": "CAVE STORY", + "folder": "/userdata/roms/cavestory", + "extensions": [ + ".exe" + ] + }, + { + "system": "CD-I", + "folder": "/userdata/roms/cdi", + "extensions": [ + ".chd", + ".cue", + ".toc", + ".nrg", + ".gdi", + ".iso", + ".cdr" + ] + }, + { + "system": "C-DOGS SDL", + "folder": "/userdata/roms/cdogs", + "extensions": [ + ".game" + ] + }, + { + "system": "COMMANDER GENIUS", + "folder": "/userdata/roms/cgenius", + "extensions": [ + ".cgenius" + ] + }, + { + "system": "CHANNEL-F", + "folder": "/userdata/roms/channelf", + "extensions": [ + ".zip", + ".rom", + ".bin", + ".chf" + ] + }, + { + "system": "SEGA CHIHIRO", + "folder": "/userdata/roms/chihiro", + "extensions": [ + ".iso" + ] + }, + { + "system": "COLOR COMPUTER", + "folder": "/userdata/roms/coco", + "extensions": [ + ".wav", + ".cas", + ".dsk", + ".ccc", + ".rom", + ".zip", + ".7z" + ] + }, + { + "system": "COLECOVISION", + "folder": "/userdata/roms/colecovision", + "extensions": [ + ".bin", + ".col", + ".rom", + ".zip", + ".7z" + ] + }, + { + "system": "COMMANDER X16", + "folder": "/userdata/roms/commanderx16", + "extensions": [ + ".bas", + ".img", + ".prg" + ] + }, + { + "system": "CORSIXTH", + "folder": "/userdata/roms/corsixth", + "extensions": [ + ".game" + ] + }, + { + "system": "COMMODORE PLUS4", + "folder": "/userdata/roms/cplus4", + "extensions": [ + ".d64", + ".prg", + ".tap", + ".m3u", + ".zip", + ".7z" + ] + }, + { + "system": "CREATIVISION", + "folder": "/userdata/roms/crvision", + "extensions": [ + ".bin", + ".rom", + ".zip", + ".7z" + ] + }, + { + "system": "DAPHNE", + "folder": "/userdata/roms/daphne", + "extensions": [ + ".daphne", + ".squashfs" + ] + }, + { + "system": "DIABLO", + "folder": "/userdata/roms/devilutionx", + "extensions": [ + ".mpq" + ] + }, + { + "system": "DOOM 3", + "folder": "/userdata/roms/doom3", + "extensions": [ + ".d3" + ] + }, + { + "system": "DOS (X86)", + "folder": "/userdata/roms/dos", + "extensions": [ + ".pc", + ".dos", + ".zip", + ".squashfs", + ".dosz", + ".m3u", + ".iso", + ".cue" + ] + }, + { + "system": "DREAMCAST", + "folder": "/userdata/roms/dreamcast", + "extensions": [ + ".cdi", + ".cue", + ".gdi", + ".chd", + ".m3u" + ] + }, + { + "system": "DXX REBIRTH", + "folder": "/userdata/roms/dxx-rebirth", + "extensions": [ + ".d1x", + ".d2x" + ] + }, + { + "system": "EASYRPG", + "folder": "/userdata/roms/easyrpg", + "extensions": [ + ".easyrpg", + ".squashfs", + ".zip" + ] + }, + { + "system": "ECWOLF", + "folder": "/userdata/roms/ecwolf", + "extensions": [ + ".ecwolf", + ".pk3", + ".squashfs" + ] + }, + { + "system": "EDUKE32", + "folder": "/userdata/roms/eduke32", + "extensions": [ + ".eduke32" + ] + }, + { + "system": "ELECTRON", + "folder": "/userdata/roms/electron", + "extensions": [ + ".wav", + ".csw", + ".uef", + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".ssd", + ".bbc", + ".img", + ".dsd", + ".adf", + ".ads", + ".adm", + ".adl", + ".rom", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "WOLFENSTEIN - ENEMY TERRITORY", + "folder": "/userdata/roms/etlegacy", + "extensions": [ + ".etl" + ] + }, + { + "system": "FALLOUT COMMUNITY EDITION", + "folder": "/userdata/roms/fallout1-ce", + "extensions": [ + ".f1ce" + ] + }, + { + "system": "FALLOUT 2 COMMUNITY EDITION", + "folder": "/userdata/roms/fallout2-ce", + "extensions": [ + ".f2ce" + ] + }, + { + "system": "FINAL BURN NEO", + "folder": "/userdata/roms/fbneo", + "extensions": [ + ".zip", + ".7z" + ] + }, + { + "system": "FAMILY COMPUTER DISK SYSTEM", + "folder": "/userdata/roms/fds", + "extensions": [ + ".fds", + ".zip", + ".7z" + ] + }, + { + "system": "FLASH PLAYER", + "folder": "/userdata/roms/flash", + "extensions": [ + ".swf" + ] + }, + { + "system": "APPLICATIONS", + "folder": "/userdata/roms/flatpak", + "extensions": [ + ".flatpak" + ] + }, + { + "system": "FM-7", + "folder": "/userdata/roms/fm7", + "extensions": [ + ".wav", + ".t77", + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".zip", + ".7z" + ] + }, + { + "system": "FM-TOWNS", + "folder": "/userdata/roms/fmtowns", + "extensions": [ + ".bin", + ".m3u", + ".cue", + ".d88", + ".d77", + ".xdf", + ".iso", + ".chd", + ".toc", + ".nrg", + ".gdi", + ".cdr", + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".zip", + ".7z" + ] + }, + { + "system": "FUTURE PINBALL", + "folder": "/userdata/roms/fpinball", + "extensions": [ + ".fpt" + ] + }, + { + "system": "ION FURY", + "folder": "/userdata/roms/fury", + "extensions": [ + ".grp" + ] + }, + { + "system": "GAMATE", + "folder": "/userdata/roms/gamate", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "GAME AND WATCH", + "folder": "/userdata/roms/gameandwatch", + "extensions": [ + ".mgw", + ".zip", + ".7z" + ] + }, + { + "system": "GAME.COM", + "folder": "/userdata/roms/gamecom", + "extensions": [ + ".bin", + ".tgc", + ".zip", + ".7z" + ] + }, + { + "system": "GAMECUBE", + "folder": "/userdata/roms/gamecube", + "extensions": [ + ".gcm", + ".iso", + ".gcz", + ".ciso", + ".wbfs", + ".rvz", + ".elf", + ".dol", + ".m3u", + ".json" + ] + }, + { + "system": "GAME GEAR", + "folder": "/userdata/roms/gamegear", + "extensions": [ + ".bin", + ".gg", + ".zip", + ".7z" + ] + }, + { + "system": "GAME POCKET COMPUTER", + "folder": "/userdata/roms/gamepock", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "GAME BOY", + "folder": "/userdata/roms/gb", + "extensions": [ + ".gb", + ".zip", + ".7z" + ] + }, + { + "system": "GAME BOY (2 PLAYERS)", + "folder": "/userdata/roms/gb2players", + "extensions": [ + ".gb", + ".gb2", + ".gbc2", + ".zip", + ".7z" + ] + }, + { + "system": "GAME BOY ADVANCE", + "folder": "/userdata/roms/gba", + "extensions": [ + ".gba", + ".zip", + ".7z" + ] + }, + { + "system": "GAME BOY COLOR", + "folder": "/userdata/roms/gbc", + "extensions": [ + ".gbc", + ".zip", + ".7z" + ] + }, + { + "system": "GAME BOY COLOR (2 PLAYERS)", + "folder": "/userdata/roms/gbc2players", + "extensions": [ + ".gbc", + ".gb2", + ".gbc2", + ".zip", + ".7z" + ] + }, + { + "system": "GAME MASTER", + "folder": "/userdata/roms/gmaster", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "GP32", + "folder": "/userdata/roms/gp32", + "extensions": [ + ".smc", + ".zip", + ".7z" + ] + }, + { + "system": "GX4000", + "folder": "/userdata/roms/gx4000", + "extensions": [ + ".dsk", + ".m3u", + ".cpr", + ".zip", + ".7z" + ] + }, + { + "system": "GZDOOM", + "folder": "/userdata/roms/gzdoom", + "extensions": [ + ".wad", + ".iwad", + ".pwad", + ".gzdoom" + ] + }, + { + "system": "HYDRA CASTLE LABYRINTH", + "folder": "/userdata/roms/hcl", + "extensions": [ + ".game" + ] + }, + { + "system": "HURRICAN", + "folder": "/userdata/roms/hurrican", + "extensions": [ + ".game" + ] + }, + { + "system": "IKEMEN", + "folder": "/userdata/roms/ikemen", + "extensions": [ + ".ikemen", + ".pc" + ] + }, + { + "system": "MATTEL INTELLIVISION", + "folder": "/userdata/roms/intellivision", + "extensions": [ + ".int", + ".bin", + ".rom", + ".zip", + ".7z" + ] + }, + { + "system": "RETURN TO CASTLE WOLFENSTEIN", + "folder": "/userdata/roms/iortcw", + "extensions": [ + ".rtcw" + ] + }, + { + "system": "JAGUAR", + "folder": "/userdata/roms/jaguar", + "extensions": [ + ".j64", + ".jag", + ".cof", + ".abs", + ".rom", + ".zip", + ".7z" + ] + }, + { + "system": "JAGUAR CD", + "folder": "/userdata/roms/jaguarcd", + "extensions": [ + ".cue", + ".cdi" + ] + }, + { + "system": "JAZZ JACKRABBIT 2", + "folder": "/userdata/roms/jazz2", + "extensions": [ + ".game" + ] + }, + { + "system": "LASER 310", + "folder": "/userdata/roms/laser310", + "extensions": [ + ".vz", + ".wav", + ".cas", + ".zip", + ".7z" + ] + }, + { + "system": "LCD GAMES", + "folder": "/userdata/roms/lcdgames", + "extensions": [ + ".mgw", + ".zip", + ".7z" + ] + }, + { + "system": "LOWRES NX", + "folder": "/userdata/roms/lowresnx", + "extensions": [ + ".nx", + ".zip", + ".7z" + ] + }, + { + "system": "LUTRO", + "folder": "/userdata/roms/lutro", + "extensions": [ + ".lutro", + ".zip", + ".7z" + ] + }, + { + "system": "ATARI LYNX", + "folder": "/userdata/roms/lynx", + "extensions": [ + ".bll", + ".lnx", + ".lyx", + ".o", + ".zip", + ".7z" + ] + }, + { + "system": "MACINTOSH", + "folder": "/userdata/roms/macintosh", + "extensions": [ + ".dsk", + ".zip", + ".7z", + ".mfi", + ".dfi", + ".hfe", + ".mfm", + ".td0", + ".imd", + ".d77", + ".d88", + ".1dd", + ".cqm", + ".cqi", + ".dsk", + ".ima", + ".img", + ".ufi", + ".ipf", + ".dc42", + ".woz", + ".2mg", + ".360", + ".chd", + ".cue", + ".toc", + ".nrg", + ".gdi", + ".iso", + ".cdr", + ".hd", + ".hdv", + ".2mg", + ".hdi" + ] + }, + { + "system": "MAME", + "folder": "/userdata/roms/mame", + "extensions": [ + ".zip", + ".7z" + ] + }, + { + "system": "MASTER SYSTEM", + "folder": "/userdata/roms/mastersystem", + "extensions": [ + ".bin", + ".sms", + ".zip", + ".7z" + ] + }, + { + "system": "MEGA DRIVE", + "folder": "/userdata/roms/megadrive", + "extensions": [ + ".bin", + ".gen", + ".md", + ".sg", + ".smd", + ".zip", + ".7z" + ] + }, + { + "system": "MEGA DUCK / COUGAR BOY", + "folder": "/userdata/roms/megaduck", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "MODEL 2", + "folder": "/userdata/roms/model2", + "extensions": [ + ".zip" + ] + }, + { + "system": "MODEL 3", + "folder": "/userdata/roms/model3", + "extensions": [ + ".zip" + ] + }, + { + "system": "MOONLIGHT EMBEDDED", + "folder": "/userdata/roms/moonlight", + "extensions": [ + ".moonlight" + ] + }, + { + "system": "MRBOOM", + "folder": "/userdata/roms/mrboom", + "extensions": [ + ".libretro" + ] + }, + { + "system": "MSU-MD", + "folder": "/userdata/roms/msu-md", + "extensions": [ + ".md", + ".zip", + ".7z", + ".squashfs" + ] + }, + { + "system": "MSX1", + "folder": "/userdata/roms/msx1", + "extensions": [ + ".dsk", + ".mx1", + ".rom", + ".zip", + ".7z", + ".cas", + ".m3u", + ".ogv", + ".openmsx" + ] + }, + { + "system": "MSX2", + "folder": "/userdata/roms/msx2", + "extensions": [ + ".dsk", + ".mx2", + ".rom", + ".zip", + ".7z", + ".cas", + ".m3u", + ".ogv", + ".openmsx" + ] + }, + { + "system": "MSX2+", + "folder": "/userdata/roms/msx2+", + "extensions": [ + ".dsk", + ".mx2", + ".rom", + ".zip", + ".7z", + ".cas", + ".m3u", + ".openmsx" + ] + }, + { + "system": "MSX TURBO-R", + "folder": "/userdata/roms/msxturbor", + "extensions": [ + ".dsk", + ".mx2", + ".rom", + ".zip", + ".7z", + ".openmsx", + ".m3u" + ] + }, + { + "system": "MUGEN", + "folder": "/userdata/roms/mugen", + "extensions": [ + ".pc" + ] + }, + { + "system": "OTHELLO MULTIVISION", + "folder": "/userdata/roms/multivision", + "extensions": [ + ".bin", + ".sg", + ".zip", + ".7z" + ] + }, + { + "system": "NINTENDO 64", + "folder": "/userdata/roms/n64", + "extensions": [ + ".z64", + ".n64", + ".v64", + ".zip", + ".7z" + ] + }, + { + "system": "NINTENDO 64 DISK DRIVE", + "folder": "/userdata/roms/n64dd", + "extensions": [ + ".z64", + ".n64", + ".ndd", + ".zip", + ".7z" + ] + }, + { + "system": "NAMCO 246/256", + "folder": "/userdata/roms/namco2x6", + "extensions": [ + ".zip" + ] + }, + { + "system": "NAOMI", + "folder": "/userdata/roms/naomi", + "extensions": [ + ".lst", + ".bin", + ".dat", + ".zip", + ".7z" + ] + }, + { + "system": "NAOMI 2", + "folder": "/userdata/roms/naomi2", + "extensions": [ + ".zip", + ".7z" + ] + }, + { + "system": "NINTENDO DS", + "folder": "/userdata/roms/nds", + "extensions": [ + ".nds", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "NEO-GEO", + "folder": "/userdata/roms/neogeo", + "extensions": [ + ".7z", + ".zip" + ] + }, + { + "system": "NEO-GEO CD", + "folder": "/userdata/roms/neogeocd", + "extensions": [ + ".cue", + ".iso", + ".chd" + ] + }, + { + "system": "NINTENDO ENTERTAINMENT SYSTEM", + "folder": "/userdata/roms/nes", + "extensions": [ + ".nes", + ".unif", + ".unf", + ".zip", + ".7z" + ] + }, + { + "system": "NEO-GEO POCKET", + "folder": "/userdata/roms/ngp", + "extensions": [ + ".ngp", + ".zip", + ".7z" + ] + }, + { + "system": "NEO-GEO POCKET COLOR", + "folder": "/userdata/roms/ngpc", + "extensions": [ + ".ngc", + ".zip", + ".7z" + ] + }, + { + "system": "ODYSSEY2", + "folder": "/userdata/roms/o2em", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "OD-COMMANDER", + "folder": "/userdata/roms/odcommander", + "extensions": [ + ".odc" + ] + }, + { + "system": "OPENBOR", + "folder": "/userdata/roms/openbor", + "extensions": [ + ".pak" + ] + }, + { + "system": "JAZZ JACKRABBIT", + "folder": "/userdata/roms/openjazz", + "extensions": [ + ".game" + ] + }, + { + "system": "OPENLARA", + "folder": "/userdata/roms/openlara", + "extensions": [ + ".croft" + ] + }, + { + "system": "PC-8800", + "folder": "/userdata/roms/pc88", + "extensions": [ + ".cmt", + ".d88", + ".u88", + ".m3u" + ] + }, + { + "system": "PC-9800", + "folder": "/userdata/roms/pc98", + "extensions": [ + ".d98", + ".zip", + ".98d", + ".fdi", + ".fdd", + ".2hd", + ".tfd", + ".d88", + ".88d", + ".hdm", + ".xdf", + ".dup", + ".cmd", + ".hdi", + ".thd", + ".nhd", + ".hdd", + ".hdn", + ".m3u" + ] + }, + { + "system": "PC ENGINE", + "folder": "/userdata/roms/pcengine", + "extensions": [ + ".pce", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "PC ENGINE CD", + "folder": "/userdata/roms/pcenginecd", + "extensions": [ + ".pce", + ".cue", + ".ccd", + ".iso", + ".img", + ".chd" + ] + }, + { + "system": "PC-FX", + "folder": "/userdata/roms/pcfx", + "extensions": [ + ".cue", + ".ccd", + ".toc", + ".chd", + ".zip", + ".7z", + ".m3u" + ] + }, + { + "system": "PDP-1", + "folder": "/userdata/roms/pdp1", + "extensions": [ + ".zip", + ".7z", + ".tap", + ".rim", + ".drm" + ] + }, + { + "system": "COMMODORE PET", + "folder": "/userdata/roms/pet", + "extensions": [ + ".a0", + ".b0", + ".crt", + ".d64", + ".d81", + ".prg", + ".tap", + ".t64", + ".m3u", + ".zip", + ".7z" + ] + }, + { + "system": "SEGA PICO", + "folder": "/userdata/roms/pico", + "extensions": [ + ".bin", + ".md", + ".zip", + ".7z" + ] + }, + { + "system": "PICO-8", + "folder": "/userdata/roms/pico8", + "extensions": [ + ".p8", + ".png", + ".m3u" + ] + }, + { + "system": "PLUG AND PLAY TV GAMES", + "folder": "/userdata/roms/plugnplay", + "extensions": [ + ".zip", + ".7z" + ] + }, + { + "system": "POKEMON MINI", + "folder": "/userdata/roms/pokemini", + "extensions": [ + ".min", + ".zip", + ".7z" + ] + }, + { + "system": "PRBOOM", + "folder": "/userdata/roms/prboom", + "extensions": [ + ".wad", + ".iwad", + ".pwad" + ] + }, + { + "system": "PLAYSTATION 2", + "folder": "/userdata/roms/ps2", + "extensions": [ + ".iso", + ".mdf", + ".nrg", + ".bin", + ".img", + ".dump", + ".gz", + ".cso", + ".chd", + ".m3u" + ] + }, + { + "system": "PLAYSTATION 3", + "folder": "/userdata/roms/ps3", + "extensions": [ + ".ps3", + ".psn", + ".squashfs" + ] + }, + { + "system": "PLAYSTATION PORTABLE", + "folder": "/userdata/roms/psp", + "extensions": [ + ".iso", + ".cso", + ".pbp", + ".chd" + ] + }, + { + "system": "PLAYSTATION VITA", + "folder": "/userdata/roms/psvita", + "extensions": [ + ".zip", + ".psvita" + ] + }, + { + "system": "PLAYSTATION", + "folder": "/userdata/roms/psx", + "extensions": [ + ".cue", + ".img", + ".mdf", + ".pbp", + ".toc", + ".cbn", + ".m3u", + ".ccd", + ".chd", + ".iso" + ] + }, + { + "system": "PV-1000", + "folder": "/userdata/roms/pv1000", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "PYGAME", + "folder": "/userdata/roms/pygame", + "extensions": [ + ".pygame" + ] + }, + { + "system": "PYXEL", + "folder": "/userdata/roms/pyxel", + "extensions": [ + ".py", + ".pyxapp" + ] + }, + { + "system": "QUAKE III", + "folder": "/userdata/roms/quake3", + "extensions": [ + ".quake3" + ] + }, + { + "system": "RAZE", + "folder": "/userdata/roms/raze", + "extensions": [ + ".raze" + ] + }, + { + "system": "REMINISCENCE", + "folder": "/userdata/roms/reminiscence", + "extensions": [ + ".rem" + ] + }, + { + "system": "RISE OF THE TRIAD", + "folder": "/userdata/roms/rott", + "extensions": [ + ".rott" + ] + }, + { + "system": "SAM COUPÉ", + "folder": "/userdata/roms/samcoupe", + "extensions": [ + ".cpm", + ".dsk", + ".sad", + ".mgt", + ".sdf", + ".td0", + ".sbt", + ".zip" + ] + }, + { + "system": "SATELLAVIEW", + "folder": "/userdata/roms/satellaview", + "extensions": [ + ".bs", + ".smc", + ".sfc", + ".zip", + ".7z", + ".squashfs" + ] + }, + { + "system": "SATURN", + "folder": "/userdata/roms/saturn", + "extensions": [ + ".cue", + ".ccd", + ".m3u", + ".chd", + ".iso", + ".zip", + ".mds" + ] + }, + { + "system": "SCUMMVM", + "folder": "/userdata/roms/scummvm", + "extensions": [ + ".scummvm", + ".squashfs" + ] + }, + { + "system": "SUPER CASSETTE VISION", + "folder": "/userdata/roms/scv", + "extensions": [ + ".bin", + ".zip", + ".0" + ] + }, + { + "system": "SDLPOP", + "folder": "/userdata/roms/sdlpop", + "extensions": [ + ".sdlpop" + ] + }, + { + "system": "32X", + "folder": "/userdata/roms/sega32x", + "extensions": [ + ".32x", + ".chd", + ".smd", + ".bin", + ".md", + ".zip", + ".7z" + ] + }, + { + "system": "SEGA CD", + "folder": "/userdata/roms/segacd", + "extensions": [ + ".cue", + ".iso", + ".chd", + ".m3u" + ] + }, + { + "system": "SG-1000", + "folder": "/userdata/roms/sg1000", + "extensions": [ + ".bin", + ".sg", + ".zip", + ".7z" + ] + }, + { + "system": "SUPER GAME BOY", + "folder": "/userdata/roms/sgb", + "extensions": [ + ".gb", + ".gbc", + ".zip", + ".7z" + ] + }, + { + "system": "SINGE", + "folder": "/userdata/roms/singe", + "extensions": [ + ".daphne", + ".squashfs" + ] + }, + { + "system": "SUPER NINTENDO ENTERTAINMENT SYSTEM", + "folder": "/userdata/roms/snes", + "extensions": [ + ".smc", + ".fig", + ".sfc", + ".gd3", + ".gd7", + ".dx2", + ".bsx", + ".swc", + ".zip", + ".7z" + ] + }, + { + "system": "SUPER DISC (MSU1)", + "folder": "/userdata/roms/snes-msu1", + "extensions": [ + ".smc", + ".sfc", + ".squashfs" + ] + }, + { + "system": "SOCRATES", + "folder": "/userdata/roms/socrates", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "SOLARUS", + "folder": "/userdata/roms/solarus", + "extensions": [ + ".zip", + ".solarus" + ] + }, + { + "system": "SONIC MANIA", + "folder": "/userdata/roms/sonic-mania", + "extensions": [ + ".sman" + ] + }, + { + "system": "SONIC 3 A.I.R.", + "folder": "/userdata/roms/sonic3-air", + "extensions": [ + ".s3air" + ] + }, + { + "system": "SONIC RETRO ENGINE", + "folder": "/userdata/roms/sonicretro", + "extensions": [ + ".son", + ".scd" + ] + }, + { + "system": "SPECTRAVIDEO SV-328", + "folder": "/userdata/roms/spectravideo", + "extensions": [ + ".zip", + ".7z", + ".cas" + ] + }, + { + "system": "STEAM", + "folder": "/userdata/roms/steam", + "extensions": [ + ".steam" + ] + }, + { + "system": "SUFAMI TURBO", + "folder": "/userdata/roms/sufami", + "extensions": [ + ".st", + ".fig", + ".bs", + ".smc", + ".sfc", + ".zip", + ".7z" + ] + }, + { + "system": "SUPER MARIO WAR", + "folder": "/userdata/roms/superbroswar", + "extensions": [ + ".game" + ] + }, + { + "system": "SUPERGRAFX", + "folder": "/userdata/roms/supergrafx", + "extensions": [ + ".pce", + ".sgx", + ".cue", + ".ccd", + ".chd", + ".zip", + ".7z" + ] + }, + { + "system": "SUPERVISION", + "folder": "/userdata/roms/supervision", + "extensions": [ + ".sv", + ".zip", + ".7z" + ] + }, + { + "system": "SUPER A'CAN", + "folder": "/userdata/roms/supracan", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "SEGA SP", + "folder": "/userdata/roms/systemsp", + "extensions": [ + ".lst", + ".bin", + ".dat", + ".zip", + ".7z" + ] + }, + { + "system": "THE FORCE ENGINE", + "folder": "/userdata/roms/theforceengine", + "extensions": [ + ".tfe" + ] + }, + { + "system": "THEXTECH", + "folder": "/userdata/roms/thextech", + "extensions": [ + ".smbx", + ".squashfs" + ] + }, + { + "system": "THOMSON - MO/TO (THEODORE)", + "folder": "/userdata/roms/thomson", + "extensions": [ + ".fd", + ".sap", + ".k7", + ".m7", + ".m5", + ".rom", + ".zip" + ] + }, + { + "system": "TI-99", + "folder": "/userdata/roms/ti99", + "extensions": [ + ".rpk", + ".wav", + ".zip", + ".7z" + ] + }, + { + "system": "TIC-80", + "folder": "/userdata/roms/tic80", + "extensions": [ + ".tic" + ] + }, + { + "system": "TRIFORCE", + "folder": "/userdata/roms/triforce", + "extensions": [ + ".gcm", + ".iso", + ".gcz", + ".ciso", + ".wbfs", + ".elf", + ".dol", + ".m3u" + ] + }, + { + "system": "TUTOR", + "folder": "/userdata/roms/tutor", + "extensions": [ + ".bin", + ".wav", + ".zip", + ".7z" + ] + }, + { + "system": "TYRIAN", + "folder": "/userdata/roms/tyrian", + "extensions": [ + ".game" + ] + }, + { + "system": "TYRQUAKE", + "folder": "/userdata/roms/tyrquake", + "extensions": [ + ".pak" + ] + }, + { + "system": "UZEBOX", + "folder": "/userdata/roms/uzebox", + "extensions": [ + ".uze" + ] + }, + { + "system": "VC 4000", + "folder": "/userdata/roms/vc4000", + "extensions": [ + ".bin", + ".rom", + ".pgm", + ".tvc", + ".zip", + ".7z" + ] + }, + { + "system": "VECTREX", + "folder": "/userdata/roms/vectrex", + "extensions": [ + ".bin", + ".gam", + ".vec", + ".zip", + ".7z" + ] + }, + { + "system": "VIDEO GAME MUSIC PLAYER", + "folder": "/userdata/roms/vgmplay", + "extensions": [ + ".vgm", + ".vgz", + ".zip", + ".7z" + ] + }, + { + "system": "VIDEOPAC+ G7400", + "folder": "/userdata/roms/videopacplus", + "extensions": [ + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "VIRCON32", + "folder": "/userdata/roms/vircon32", + "extensions": [ + ".v32", + ".zip" + ] + }, + { + "system": "VIRTUAL BOY", + "folder": "/userdata/roms/virtualboy", + "extensions": [ + ".vb", + ".zip", + ".7z" + ] + }, + { + "system": "TANDY VIDEO INFORMATION SYSTEM", + "folder": "/userdata/roms/vis", + "extensions": [ + ".chd", + ".cue", + ".toc", + ".nrg", + ".gdi", + ".iso", + ".cdr" + ] + }, + { + "system": "QUAKE II", + "folder": "/userdata/roms/vitaquake2", + "extensions": [ + ".pak", + ".zip", + ".7zip" + ] + }, + { + "system": "VISUAL PINBALL X", + "folder": "/userdata/roms/vpinball", + "extensions": [ + ".vpx" + ] + }, + { + "system": "V.SMILE", + "folder": "/userdata/roms/vsmile", + "extensions": [ + ".u1", + ".u3", + ".bin", + ".zip", + ".7z" + ] + }, + { + "system": "WASM4", + "folder": "/userdata/roms/wasm4", + "extensions": [ + ".wasm" + ] + }, + { + "system": "WII", + "folder": "/userdata/roms/wii", + "extensions": [ + ".gcm", + ".iso", + ".gcz", + ".ciso", + ".wbfs", + ".wad", + ".rvz", + ".elf", + ".dol", + ".m3u", + ".json" + ] + }, + { + "system": "WII U", + "folder": "/userdata/roms/wiiu", + "extensions": [ + ".wua", + ".wup", + ".wud", + ".wux", + ".rpx", + ".squashfs", + ".wuhb" + ] + }, + { + "system": "WINDOWS", + "folder": "/userdata/roms/windows", + "extensions": [ + ".pc", + ".exe", + ".wine", + ".wsquashfs", + ".wtgz" + ] + }, + { + "system": "INSTALL A NEW WINDOWS GAME", + "folder": "/userdata/roms/windows_installers", + "extensions": [ + ".exe", + ".iso" + ] + }, + { + "system": "WONDERSWAN", + "folder": "/userdata/roms/wswan", + "extensions": [ + ".ws", + ".zip", + ".7z" + ] + }, + { + "system": "WONDERSWAN COLOR", + "folder": "/userdata/roms/wswanc", + "extensions": [ + ".wsc", + ".zip", + ".7z" + ] + }, + { + "system": "SHARP X1", + "folder": "/userdata/roms/x1", + "extensions": [ + ".dx1", + ".zip", + ".2d", + ".2hd", + ".tfd", + ".d88", + ".88d", + ".hdm", + ".xdf", + ".dup", + ".cmd", + ".7z" + ] + }, + { + "system": "SHARP X68000", + "folder": "/userdata/roms/x68000", + "extensions": [ + ".dim", + ".img", + ".d88", + ".88d", + ".hdm", + ".dup", + ".2hd", + ".xdf", + ".hdf", + ".cmd", + ".m3u", + ".zip", + ".7z" + ] + }, + { + "system": "HALF-LIFE 1", + "folder": "/userdata/roms/xash3d_fwgs", + "extensions": [ + ".game" + ] + }, + { + "system": "XBOX", + "folder": "/userdata/roms/xbox", + "extensions": [ + ".iso", + ".squashfs" + ] + }, + { + "system": "XBOX 360", + "folder": "/userdata/roms/xbox360", + "extensions": [ + ".iso", + ".xex", + ".xbox360", + ".zar" + ] + }, + { + "system": "ATARI XE GAME SYSTEM", + "folder": "/userdata/roms/xegs", + "extensions": [ + ".atr", + ".dsk", + ".xfd", + ".bin", + ".rom", + ".car", + ".zip", + ".7z" + ] + }, + { + "system": "XRICK", + "folder": "/userdata/roms/xrick", + "extensions": [ + ".zip" + ] + }, + { + "system": "ZELDA CLASSIC", + "folder": "/userdata/roms/zc210", + "extensions": [ + ".qst" + ] + }, + { + "system": "ZX81", + "folder": "/userdata/roms/zx81", + "extensions": [ + ".tzx", + ".p", + ".zip", + ".7z" + ] + }, + { + "system": "ZX SPECTRUM", + "folder": "/userdata/roms/zxspectrum", + "extensions": [ + ".tzx", + ".tap", + ".z80", + ".rzx", + ".scl", + ".trd", + ".dsk", + ".zip", + ".7z" + ] + } +] \ No newline at end of file diff --git a/update_gamelist.py b/update_gamelist.py new file mode 100644 index 0000000..f73d339 --- /dev/null +++ b/update_gamelist.py @@ -0,0 +1,87 @@ +import os +import xml.dom.minidom +import xml.etree.ElementTree as ET +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +GAMELIST_FILE = "/userdata/roms/ports/gamelist.xml" +RGSX_ENTRY = { + "path": "./RGSX/RGSX.sh", + "name": "RGSX", + "desc": "Retro Games Sets X - Games Downloader", + "image": "./images/RGSX.png", + "marquee": "./images/RGSX.png", + "thumbnail": "./images/RGSX.png", + "fanart": "./images/RGSX.png", + "rating": "1", + "releasedate": "20250620T165718", + "developer": "RetroGameSets.fr", + "genre": "Compilation, Various / Utilities", + "playcount": "251", + "lastplayed": "20250621T234656", + "gametime": "30830", + "lang": "fr" +} + +def update_gamelist(): + try: + # Si le fichier n'existe pas, est vide ou non valide, crĂ©er une nouvelle structure + if not os.path.exists(GAMELIST_FILE) or os.path.getsize(GAMELIST_FILE) == 0: + logger.info(f"CrĂ©ation de {GAMELIST_FILE}") + root = ET.Element("gameList") + else: + try: + logger.info(f"Lecture de {GAMELIST_FILE}") + tree = ET.parse(GAMELIST_FILE) + root = tree.getroot() + if root.tag != "gameList": + logger.info(f"{GAMELIST_FILE} n'a pas de balise , crĂ©ation d'une nouvelle structure") + root = ET.Element("gameList") + except ET.ParseError: + logger.info(f"{GAMELIST_FILE} est invalide, crĂ©ation d'une nouvelle structure") + root = ET.Element("gameList") + + # Supprimer l'ancienne entrĂ©e RGSX + for game in root.findall("game"): + path = game.find("path") + if path is not None and path.text == "./RGSX/RGSX.sh": + root.remove(game) + logger.info("Ancienne entrĂ©e RGSX supprimĂ©e") + + # Ajouter la nouvelle entrĂ©e + game_elem = ET.SubElement(root, "game") + for key, value in RGSX_ENTRY.items(): + elem = ET.SubElement(game_elem, key) + elem.text = value + logger.info("Nouvelle entrĂ©e RGSX ajoutĂ©e") + + # GĂ©nĂ©rer le XML avec minidom pour une indentation correcte + rough_string = '\n' + ET.tostring(root, encoding='unicode') + parsed = xml.dom.minidom.parseString(rough_string) + pretty_xml = parsed.toprettyxml(indent="\t", encoding='utf-8').decode('utf-8') + # Supprimer les lignes vides inutiles gĂ©nĂ©rĂ©es par minidom + pretty_xml = '\n'.join(line for line in pretty_xml.split('\n') if line.strip()) + with open(GAMELIST_FILE, 'w', encoding='utf-8') as f: + f.write(pretty_xml) + logger.info(f"{GAMELIST_FILE} mis Ă  jour avec succĂšs") + + # DĂ©finir les permissions + os.chmod(GAMELIST_FILE, 0o644) + + except Exception as e: + logger.error(f"Erreur lors de la mise Ă  jour de {GAMELIST_FILE}: {e}") + raise + +def load_gamelist(path): + """Charge le fichier gamelist.xml.""" + try: + tree = ET.parse(path) + return tree.getroot() + except (FileNotFoundError, ET.ParseError) as e: + logging.error(f"Erreur lors de la lecture de {path} : {e}") + return None + +if __name__ == "__main__": + update_gamelist() \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..ea2b970 --- /dev/null +++ b/utils.py @@ -0,0 +1,623 @@ +import shutil +import pygame # type: ignore +import re +import json +import os +import logging +import platform +import subprocess +import config +import threading +import zipfile +import time +import random +from config import JSON_EXTENSIONS, SAVE_FOLDER +from history import save_history +from language import _ # Import de la fonction de traduction + +from datetime import datetime + + +logger = logging.getLogger(__name__) +# DĂ©sactiver les logs DEBUG de urllib3 e requests pour supprimer les messages de connexion HTTP +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("requests").setLevel(logging.WARNING) + +# Liste globale pour stocker les systĂšmes avec une erreur 404 +unavailable_systems = [] + + +# DĂ©tection systĂšme non-PC +def detect_non_pc(): + arch = platform.machine() + try: + result = subprocess.run(["batocera-es-swissknife", "--arch"], capture_output=True, text=True, timeout=2) + if result.returncode == 0: + arch = result.stdout.strip() + logger.debug(f"Architecture via batocera-es-swissknife: {arch}") + except (subprocess.SubprocessError, FileNotFoundError): + logger.debug(f"batocera-es-swissknife non disponible, utilisation de platform.machine(): {arch}") + + is_non_pc = arch not in ["x86_64", "amd64"] + logger.debug(f"SystĂšme dĂ©tectĂ©: {platform.system()}, architecture: {arch}, is_non_pc={is_non_pc}") + return is_non_pc + + +# Fonction pour charger le fichier JSON des extensions supportĂ©es +def load_extensions_json(): + """Charge le fichier JSON contenant les extensions supportĂ©es.""" + try: + with open(JSON_EXTENSIONS, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"Erreur lors de la lecture de {JSON_EXTENSIONS}: {e}") + return [] + +def check_extension_before_download(url, platform, game_name): + """VĂ©rifie l'extension avant de lancer le tĂ©lĂ©chargement et retourne un tuple de 4 Ă©lĂ©ments.""" + try: + sanitized_name = sanitize_filename(game_name) + extensions_data = load_extensions_json() + if not extensions_data: + logger.error(f"Fichier {JSON_EXTENSIONS} vide ou introuvable") + return None + + is_supported = is_extension_supported(sanitized_name, platform, extensions_data) + extension = os.path.splitext(sanitized_name)[1].lower() + is_archive = extension in (".zip", ".rar") + + if is_supported: + logger.debug(f"L'extension de {sanitized_name} est supportĂ©e pour {platform}") + return (url, platform, game_name, False) + elif is_archive: + logger.debug(f"Archive {extension.upper()} dĂ©tectĂ©e pour {sanitized_name}, extraction automatique prĂ©vue") + return (url, platform, game_name, True) + else: + logger.debug(f"Extension non supportĂ©e ({extension}) pour {sanitized_name}, avertissement affichĂ©") + return (url, platform, game_name, False) + except Exception as e: + logger.error(f"Erreur vĂ©rification extension {url}: {str(e)}") + return None + +# Fonction pour vĂ©rifier si l'extension est supportĂ©e pour une plateforme donnĂ©e +def is_extension_supported(filename, platform, extensions_data): + """VĂ©rifie si l'extension du fichier est supportĂ©e pour la plateforme donnĂ©e.""" + extension = os.path.splitext(filename)[1].lower() + dest_dir = None + for platform_dict in config.platform_dicts: + if platform_dict["platform"] == platform: + dest_dir = platform_dict.get("folder") + break + if not dest_dir: + logger.warning(f"Aucun dossier 'folder' trouvĂ© pour la plateforme {platform}") + dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform) + for system in extensions_data: + if system["folder"] == dest_dir: + return extension in system["extensions"] + logger.warning(f"Aucun systĂšme trouvĂ© pour le dossier {dest_dir}") + return False + + + + +# Fonction pour charger sources.json +def load_sources(): + """Charge les sources depuis sources.json et initialise les plateformes.""" + sources_path = os.path.join(config.APP_FOLDER, "sources.json") + logger.debug(f"Chargement de {sources_path}") + try: + with open(sources_path, 'r', encoding='utf-8') as f: + sources = json.load(f) + sources = sorted(sources, key=lambda x: x.get("nom", x.get("platform", "")).lower()) + config.platforms = [source["platform"] for source in sources] + config.platform_dicts = sources + config.platform_names = {source["platform"]: source["nom"] for source in sources} + config.games_count = {platform: 0 for platform in config.platforms} # Initialiser Ă  0 + # Charger les jeux pour chaque plateforme + loaded_platforms = set() # Pour suivre les plateformes dĂ©jĂ  loguĂ©es + for platform in config.platforms: + games = load_games(platform) + config.games_count[platform] = len(games) + if platform not in loaded_platforms: + loaded_platforms.add(platform) + # Appeler write_unavailable_systems une seule fois aprĂšs la boucle + write_unavailable_systems() # Assurez-vous que cette fonction est dĂ©finie + return sources + except Exception as e: + logger.error(f"Erreur lors du chargement de sources.json : {str(e)}") + return [] + +def load_games(platform_id): + """Charge les jeux pour une plateforme donnĂ©e en utilisant platform_id et teste la premiĂšre URL.""" + games_path = os.path.join(config.APP_FOLDER, "games", f"{platform_id}.json") + logger.debug(f"Chargement des jeux pour {platform_id} depuis {games_path}") + try: + with open(games_path, 'r', encoding='utf-8') as f: + games = json.load(f) + + # Tester la premiĂšre URL si la liste n'est pas vide + # if games and len(games) > 0 and len(games[0]) > 1: + # first_url = games[0][1] + # try: + # response = requests.head(first_url, timeout=5, allow_redirects=True) + # if response.status_code not in (200, 303): # Ne logger que les codes autres que 200 et 303 + # logger.debug(f"https://{first_url} \"HEAD {first_url} HTTP/1.1\" {response.status_code} 0") + # if response.status_code == 404: + # logger.error(f"URL non accessible pour {platform_id} : {first_url} (code 404)") + # unavailable_systems.append(platform_id) # Assurez-vous que unavailable_systems est dĂ©fini + # except requests.RequestException as e: + # logger.error(f"Erreur lors du test de l'URL pour {platform_id} : {first_url} ({str(e)})") + # else: + # logger.debug(f"Aucune URL Ă  tester pour {platform_id} (liste vide ou mal formĂ©e)") + + logger.debug(f"Jeux chargĂ©s pour {platform_id}: {len(games)} jeux") + return games + except Exception as e: + logger.error(f"Erreur lors du chargement des jeux pour {platform_id} : {str(e)}") + return [] + +def write_unavailable_systems(): + """Écrit la liste des systĂšmes avec une erreur 404 dans un fichier texte.""" + if not unavailable_systems: + logger.debug("Aucun systĂšme avec des liens HS, rien Ă  Ă©crire dans le fichier.") + return + + # Formater la date et l'heure pour le nom du fichier + current_time = datetime.now() + timestamp = current_time.strftime("%d-%m-%Y-%H-%M") + log_dir = os.path.join(os.path.dirname(config.APP_FOLDER), "logs", "RGSX") + log_file = os.path.join(log_dir, f"systemes_unavailable_{timestamp}.txt") + + try: + # CrĂ©er le rĂ©pertoire s'il n'existe pas + os.makedirs(log_dir, exist_ok=True) + + # Écrire les systĂšmes dans le fichier + with open(log_file, 'w', encoding='utf-8') as f: + f.write("SystĂšmes avec une erreur 404 :\n") + for system in unavailable_systems: + f.write(f"{system}\n") + logger.debug(f"Fichier Ă©crit : {log_file} avec {len(unavailable_systems)} systĂšmes") + except Exception as e: + logger.error(f"Erreur lors de l'Ă©criture du fichier {log_file} : {str(e)}") + +def truncate_text_middle(text, font, max_width, is_filename=True): + """Tronque le texte en insĂ©rant '...' au milieu, en prĂ©servant le dĂ©but et la fin. + Si is_filename=False, ne supprime pas l'extension.""" + # Supprimer l'extension uniquement si is_filename est True + if is_filename: + text = text.rsplit('.', 1)[0] if '.' in text else text + text_width = font.size(text)[0] + if text_width <= max_width: + return text + ellipsis = "..." + ellipsis_width = font.size(ellipsis)[0] + max_text_width = max_width - ellipsis_width + if max_text_width <= 0: + return ellipsis + + # Diviser la largeur disponible entre dĂ©but et fin, en priorisant la fin + chars = list(text) + left = [] + right = [] + left_width = 0 + right_width = 0 + left_idx = 0 + right_idx = len(chars) - 1 + + # PrĂ©server plus de caractĂšres Ă  droite pour garder le '%' + while left_idx <= right_idx and (left_width + right_width) < max_text_width: + # Ajouter Ă  droite en prioritĂ© + if left_idx <= right_idx: + right.insert(0, chars[right_idx]) + right_width = font.size(''.join(right))[0] + if left_width + right_width > max_text_width: + right.pop(0) + break + right_idx -= 1 + # Ajouter Ă  gauche seulement si nĂ©cessaire + if left_idx < right_idx: + left.append(chars[left_idx]) + left_width = font.size(''.join(left))[0] + if left_width + right_width > max_text_width: + left.pop() + break + left_idx += 1 + + # Reculer jusqu'Ă  un espace pour Ă©viter de couper un mot + while left and left[-1] != ' ' and left_width + right_width > max_text_width: + left.pop() + left_width = font.size(''.join(left))[0] if left else 0 + while right and right[0] != ' ' and left_width + right_width > max_text_width: + right.pop(0) + right_width = font.size(''.join(right))[0] if right else 0 + + return ''.join(left).rstrip() + ellipsis + ''.join(right).lstrip() + +def truncate_text_end(text, font, max_width): + """Tronque le texte Ă  la fin pour qu'il tienne dans max_width avec la police donnĂ©e.""" + if not isinstance(text, str): + logger.error(f"Texte non valide: {text}") + return "" + if not isinstance(font, pygame.font.Font): + logger.error("Police non valide dans truncate_text_end") + return text # Retourne le texte brut si la police est invalide + + try: + if font.size(text)[0] <= max_width: + return text + + truncated = text + while len(truncated) > 0 and font.size(truncated + "...")[0] > max_width: + truncated = truncated[:-1] + return truncated + "..." if len(truncated) < len(text) else text + except Exception as e: + logger.error(f"Erreur lors du rendu du texte '{text}': {str(e)}") + return text # Retourne le texte brut en cas d'erreur + +def sanitize_filename(name): + """Sanitise les noms de fichiers en remplaçant les caractĂšres interdits.""" + return re.sub(r'[<>:"/\/\\|?*]', '_', name).strip() + +def wrap_text(text, font, max_width): + """Divise le texte en lignes pour respecter la largeur maximale, en coupant les mots longs si nĂ©cessaire.""" + if not isinstance(text, str): + text = str(text) if text is not None else "" + + words = text.split(' ') + lines = [] + current_line = '' + + for word in words: + # Si le mot seul dĂ©passe max_width, le couper caractĂšre par caractĂšre + if font.render(word, True, (255, 255, 255)).get_width() > max_width: + temp_line = current_line + for char in word: + test_line = temp_line + (' ' if temp_line else '') + char + test_surface = font.render(test_line, True, (255, 255, 255)) + if test_surface.get_width() <= max_width: + temp_line = test_line + else: + if temp_line: + lines.append(temp_line) + temp_line = char + current_line = temp_line + else: + # Comportement standard pour les mots normaux + test_line = current_line + (' ' if current_line else '') + word + test_surface = font.render(test_line, True, (255, 255, 255)) + if test_surface.get_width() <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines + +def load_system_image(platform_dict): + """Charge une image systĂšme depuis le chemin spĂ©cifiĂ© dans system_image.""" + image_path = platform_dict.get("system_image") + platform_name = platform_dict.get("platform", "unknown") + #logger.debug(f"Chargement de l'image systĂšme pour {platform_name} depuis {image_path}") + try: + if not os.path.exists(image_path): + logger.error(f"Image introuvable pour {platform_name} Ă  {image_path}") + return None + return pygame.image.load(image_path).convert_alpha() + except Exception as e: + logger.error(f"Erreur lors du chargement de l'image pour {platform_name} : {str(e)}") + return None + +def extract_zip_data(zip_path, dest_dir, url): + """Extrait le contenu du fichier ZIP dans le dossier config.APP_FOLDER sans progression a l'ecran""" + logger.debug(f"Extraction de {zip_path} dans {dest_dir}") + try: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.testzip() # VĂ©rifier l'intĂ©gritĂ© de l'archive + for info in zip_ref.infolist(): + if info.is_dir(): + continue + file_path = os.path.join(dest_dir, info.filename) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with zip_ref.open(info) as source, open(file_path, 'wb') as dest: + shutil.copyfileobj(source, dest) + logger.info(f"Extraction terminĂ©e de {zip_path}") + return True, "Extraction terminĂ©e avec succĂšs" + except zipfile.BadZipFile as e: + logger.error(f"Erreur: Archive ZIP corrompue: {str(e)}") + return False, _("utils_corrupt_zip").format(str(e)) + + + +def extract_zip(zip_path, dest_dir, url): + """Extrait le contenu du fichier ZIP dans le dossier cible avec un suivi progressif de la progression.""" + logger.debug(f"Extraction de {zip_path} dans {dest_dir}") + try: + lock = threading.Lock() + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.testzip() # VĂ©rifier l'intĂ©gritĂ© de l'archive + total_size = sum(info.file_size for info in zip_ref.infolist() if not info.is_dir()) + logger.info(f"Taille totale Ă  extraire: {total_size} octets") + if total_size == 0: + logger.warning("ZIP vide ou ne contenant que des dossiers") + return True, "ZIP vide extrait avec succĂšs" + + extracted_size = 0 + os.makedirs(dest_dir, exist_ok=True) + chunk_size = 2048 # RĂ©duire pour plus de mises Ă  jour + last_save_time = time.time() + save_interval = 0.5 # Sauvegarder toutes les 0.5 secondes + for info in zip_ref.infolist(): + if info.is_dir(): + continue + file_path = os.path.join(dest_dir, info.filename) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with zip_ref.open(info) as source, open(file_path, 'wb') as dest: + file_size = info.file_size + file_extracted = 0 + while True: + chunk = source.read(chunk_size) + if not chunk: + break + dest.write(chunk) + file_extracted += len(chunk) + extracted_size += len(chunk) + current_time = time.time() + with lock: + # VĂ©rifier si config.history est une liste avant d'itĂ©rer + if isinstance(config.history, list): + for entry in config.history: + # VĂ©rifier si l'entrĂ©e a les clĂ©s nĂ©cessaires et correspond Ă  notre tĂ©lĂ©chargement + if "status" in entry and entry["status"] in ["TĂ©lĂ©chargement", "Extracting", "downloading"]: + # Chercher par URL si disponible + if "url" in entry and entry["url"] == url: + # Calculer le pourcentage correctement et le limiter entre 0 et 100 + progress_percent = int(extracted_size / total_size * 100) if total_size > 0 else 0 + progress_percent = max(0, min(100, progress_percent)) + + entry["status"] = "Extracting" + entry["progress"] = progress_percent + entry["message"] = "Extraction en cours" + + if current_time - last_save_time >= save_interval: + save_history(config.history) + last_save_time = current_time + logger.debug(f"Extraction en cours: {info.filename}, file_extracted={file_extracted}/{file_size}, total_extracted={extracted_size}/{total_size}, progression={progress_percent:.1f}%") + + config.needs_redraw = True + break + os.chmod(file_path, 0o644) + + for root, dirs, files in os.walk(dest_dir): + for dir_name in dirs: + os.chmod(os.path.join(root, dir_name), 0o755) + + try: + os.remove(zip_path) + logger.info(f"Fichier ZIP {zip_path} extrait dans {dest_dir} et supprimĂ©") + + # Mettre Ă  jour le statut final dans l'historique + if isinstance(config.history, list): + for entry in config.history: + if "status" in entry and entry["status"] == "Extracting": + entry["status"] = "Download_OK" + entry["progress"] = 100 + # Utiliser une variable intermĂ©diaire pour stocker le message + message_text = _("utils_extracted").format(os.path.basename(zip_path)) + entry["message"] = message_text + save_history(config.history) + config.needs_redraw = True + break + + return True, _("utils_extracted").format(os.path.basename(zip_path)) + except Exception as e: + logger.error(f"Erreur lors de la finalisation de l'extraction: {str(e)}") + return True, _("utils_extracted").format(os.path.basename(zip_path)) + except zipfile.BadZipFile as e: + logger.error(f"Erreur: Archive ZIP corrompue: {str(e)}") + return False, _("utils_corrupt_zip").format(str(e)) + except PermissionError as e: + logger.error(f"Erreur: Permission refusĂ©e lors de l'extraction: {str(e)}") + return False, _("utils_permission_denied").format(str(e)) + except Exception as e: + logger.error(f"Erreur lors de l'extraction de {zip_path}: {str(e)}") + return False, _("utils_extraction_failed").format(str(e)) + + +# Fonction pour extraire le contenu d'un fichier RAR +def extract_rar(rar_path, dest_dir, url): + """Extrait le contenu du fichier RAR dans le dossier cible, prĂ©servant la structure des dossiers.""" + try: + lock = threading.Lock() + os.makedirs(dest_dir, exist_ok=True) + + result = subprocess.run(['unrar'], capture_output=True, text=True) + if result.returncode not in [0, 1]: + logger.error("Commande unrar non disponible") + return False, _("utils_unrar_unavailable") + + result = subprocess.run(['unrar', 'l', '-v', rar_path], capture_output=True, text=True) + if result.returncode != 0: + error_msg = result.stderr.strip() + logger.error(f"Erreur lors de la liste des fichiers RAR: {error_msg}") + return False, _("utils_rar_list_failed").format(error_msg) + + logger.debug(f"Sortie brute de 'unrar l -v {rar_path}':\n{result.stdout}") + + total_size = 0 + files_to_extract = [] + root_dirs = set() + lines = result.stdout.splitlines() + in_file_list = False + for line in lines: + if line.startswith("----"): + in_file_list = not in_file_list + continue + if in_file_list: + match = re.match(r'^\s*(\S+)\s+(\d+)\s+\d*\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(.+)$', line) + if match: + attrs = match.group(1) + file_size = int(match.group(2)) + file_date = match.group(3) + file_name = match.group(4).strip() + if 'D' not in attrs: + files_to_extract.append((file_name, file_size)) + total_size += file_size + root_dir = file_name.split('/')[0] if '/' in file_name else '' + if root_dir: + root_dirs.add(root_dir) + logger.debug(f"Ligne parsĂ©e: {file_name}, taille: {file_size}, date: {file_date}") + else: + logger.debug(f"Dossier ignorĂ©: {file_name}") + else: + logger.debug(f"Ligne ignorĂ©e (format inattendu): {line}") + + logger.info(f"Taille totale Ă  extraire (RAR): {total_size} octets") + logger.debug(f"Fichiers Ă  extraire: {files_to_extract}") + logger.debug(f"Dossiers racines dĂ©tectĂ©s: {root_dirs}") + if total_size == 0: + logger.warning("RAR vide, ne contenant que des dossiers, ou erreur de parsing") + return False, "RAR vide ou erreur lors de la liste des fichiers" + + try: + with lock: + # VĂ©rifier si l'URL existe dans config.download_progress + if url not in config.download_progress: + config.download_progress[url] = {} + config.download_progress[url]["downloaded_size"] = 0 + config.download_progress[url]["total_size"] = total_size + config.download_progress[url]["status"] = "Extracting" + config.download_progress[url]["progress_percent"] = 0 + config.needs_redraw = True + except Exception as e: + logger.error(f"Erreur lors de la mise Ă  jour de la progression: {str(e)}") + # Continuer l'extraction mĂȘme en cas d'erreur de mise Ă  jour de la progression + + escaped_rar_path = rar_path.replace(" ", "\\ ") + escaped_dest_dir = dest_dir.replace(" ", "\\ ") + process = subprocess.Popen(['unrar', 'x', '-y', escaped_rar_path, escaped_dest_dir], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = process.communicate() + + if process.returncode != 0: + logger.error(f"Erreur lors de l'extraction de {rar_path}: {stderr}") + return False, f"Erreur lors de l'extraction: {stderr}" + + extracted_size = 0 + extracted_files = [] + total_files = len(files_to_extract) + for i, (expected_file, file_size) in enumerate(files_to_extract): + file_path = os.path.join(dest_dir, expected_file) + if os.path.exists(file_path): + extracted_size += file_size + extracted_files.append(expected_file) + os.chmod(file_path, 0o644) + logger.debug(f"Fichier extrait: {expected_file}, taille: {file_size}, chemin: {file_path}") + try: + with lock: + if url in config.download_progress: + config.download_progress[url]["downloaded_size"] = extracted_size + config.download_progress[url]["status"] = "Extracting" + config.download_progress[url]["progress_percent"] = ((i + 1) / total_files * 100) if total_files > 0 else 0 + config.needs_redraw = True + except Exception as e: + logger.error(f"Erreur lors de la mise Ă  jour de la progression d'extraction: {str(e)}") + # Continuer l'extraction mĂȘme en cas d'erreur de mise Ă  jour de la progression + else: + logger.warning(f"Fichier non trouvĂ© aprĂšs extraction: {expected_file}") + + missing_files = [f for f, _ in files_to_extract if f not in extracted_files] + if missing_files: + logger.warning(f"Fichiers non extraits: {', '.join(missing_files)}") + return False, f"Fichiers non extraits: {', '.join(missing_files)}" + + ps3_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), "ps3") + if dest_dir == ps3_dir and len(root_dirs) == 1: + root_dir = root_dirs.pop() + old_path = os.path.join(dest_dir, root_dir) + new_path = os.path.join(dest_dir, f"{root_dir}.ps3") + if os.path.isdir(old_path): + try: + os.rename(old_path, new_path) + logger.info(f"Dossier renommĂ©: {old_path} -> {new_path}") + except Exception as e: + logger.error(f"Erreur lors du renommage de {old_path} en {new_path}: {str(e)}") + return False, f"Erreur lors du renommage du dossier: {str(e)}" + else: + logger.warning(f"Dossier racine {old_path} non trouvĂ© aprĂšs extraction") + elif dest_dir == ps3_dir and len(root_dirs) > 1: + logger.warning(f"Plusieurs dossiers racines dĂ©tectĂ©s dans l'archive: {root_dirs}. Aucun renommage effectuĂ©.") + + for root, dirs, files in os.walk(dest_dir): + for dir_name in dirs: + os.chmod(os.path.join(root, dir_name), 0o755) + + os.remove(rar_path) + logger.info(f"Fichier RAR {rar_path} extrait dans {dest_dir} et supprimĂ©") + return True, "RAR extrait avec succĂšs" + except Exception as e: + logger.error(f"Erreur lors de l'extraction de {rar_path}: {str(e)}") + # Ne pas renvoyer l'URL comme message d'erreur + return False, f"Erreur lors de l'extraction: {str(e)}" + finally: + if os.path.exists(rar_path): + try: + os.remove(rar_path) + logger.info(f"Fichier RAR {rar_path} supprimĂ© aprĂšs Ă©chec de l'extraction") + except Exception as e: + logger.error(f"Erreur lors de la suppression de {rar_path}: {str(e)}") + +def play_random_music(music_files, music_folder, current_music=None): + """Joue une musique alĂ©atoire et configure l'Ă©vĂ©nement de fin.""" + if music_files: + # Éviter de rejouer la mĂȘme musique consĂ©cutivement + available_music = [f for f in music_files if f != current_music] + if not available_music: # Si une seule musique, on la reprend + available_music = music_files + music_file = random.choice(available_music) + music_path = os.path.join(music_folder, music_file) + logger.debug(f"Lecture de la musique : {music_path}") + pygame.mixer.music.load(music_path) + pygame.mixer.music.set_volume(0.5) + pygame.mixer.music.play(loops=0) # Jouer une seule fois + pygame.mixer.music.set_endevent(pygame.USEREVENT + 1) # ÉvĂ©nement de fin + set_music_popup(music_file) # Afficher le nom de la musique dans la popup + return music_file # Retourner la nouvelle musique pour mise Ă  jour + else: + logger.debug("Aucune musique trouvĂ©e dans /RGSX/assets/music") + return current_music + +def set_music_popup(music_name): + """DĂ©finit le nom de la musique Ă  afficher dans la popup.""" + global current_music_name, music_popup_start_time + current_music_name = f"♬ {os.path.splitext(music_name)[0]}" # Utilise l'emoji ♬ directement + music_popup_start_time = pygame.time.get_ticks() / 1000 # Temps actuel en secondes + +def load_api_key_1fichier(): + """Charge la clĂ© API 1fichier depuis le dossier de sauvegarde, crĂ©e le fichier si absent.""" + api_path = os.path.join(SAVE_FOLDER, "1fichierAPI.txt") + try: + # VĂ©rifie si le fichier existe dĂ©jĂ  + if not os.path.exists(api_path): + # CrĂ©e le fichier vide si absent + with open(api_path, "w") as f: + f.write("") + logger.info(f"Fichier de clĂ© API créé : {api_path}") + except OSError as e: + logger.error(f"Erreur lors de la crĂ©ation du fichier de clĂ© API : {e}") + return "" + # Lit la clĂ© API depuis le fichier + try: + with open(api_path, "r", encoding="utf-8") as f: + api_key = f.read().strip() + logger.debug(f"ClĂ© API 1fichier chargĂ©e : {api_key}") + if not api_key: + logger.warning("ClĂ© API 1fichier vide, veuillez la renseigner dans le fichier pour pouvoir utiliser les fonctionnalitĂ©s de tĂ©lĂ©chargement sur 1fichier.") + return api_key + except OSError as e: + logger.error(f"Erreur lors de la lecture de la clĂ© API : {e}") + return "" \ No newline at end of file