diff --git a/RetroBoxLED_GUItools/README.es.md b/RetroBoxLED_GUItools/README.es.md new file mode 100644 index 0000000..0b338b3 --- /dev/null +++ b/RetroBoxLED_GUItools/README.es.md @@ -0,0 +1,241 @@ +# 🎮 RetroBoxLED + +**Firmware ESP32** para **Recalbox** LED marquee (paneles HUB75/DMD 128x32 P4). + +✅ Compatible con Recalbox 10.1-PATREON-1-ALPHA-6.1 + +--- + +🌐 [English](README.md) | [Français](README.fr.md) | [Español](README.es.md) + +--- + +## ℹ️ Info + +Anteriormente desarrollé aplicaciones de gestión en C++, C# y VB .Net para proyectos personales y profesionales. +Debo admitir que la IA me ayudó mucho a construir esto en solo unos días. +Obviamente no es perfecto. Algunas listas grandes como ARCADE, MAME o FBNEO tardan mucho en mostrar una imagen. Por eso mantengo este proyecto abierto a todos, esperando que alguien lo mejore. + +## ✨ Características + +- **Reproducción de GIFs y PNGs** : Reproducción de GIFs y PNGs (`/Arcade`, `/BEST_OF_TOP_30`, `/Pixel_Art`, etc.) +- **Fallback** : `/systems/_defaults/_default.png` +- **Playlists** : `Arcade.txt`, `Favorites.txt`, `Consoles.txt` +- **MQTT** : Eventos de EmulationStation (`rungame`, `shutdown`, etc.) +- **Recalbox** : Modo Arcade automático + +## ⭐ Funcionamiento + +Por defecto, el ESP32 reproduce una playlist de GIFs. +En cuanto recibe información por MQTT, cambia automáticamente al modo ARCADE. +Cuando Recalbox se apaga, el ESP32 reanuda la reproducción de la playlist. +Si falta un GIF o PNG, utilizará el archivo de reemplazo ubicado en `/systems/_defaults`. + +## 📁 Estructura de la tarjeta SD + +La tarjeta SD debe estar formateada en FAT32 con la siguiente estructura. +Copie la carpeta `_defaults` en el directorio `systems` de su tarjeta SD. + +``` +RetroBoxLED SD Card/ +├── gifs/ +│ ├── Arcade/, BEST_OF_TOP_30/, Pixel_Art/ | GIFs +├── systems/ +│ ├── mame/, neogeo/, snes/ | Sistemas +│ │ ├── juego.gif o juego.png | Imagen de los juegos +│ ├── _defaults/ | Archivos de reemplazo +├── playlists/ +│ ├── Arcade.txt, Favorites.txt, Consoles.txt +``` + +## 🚀 Instalación + +Antes de usar, siga estos pasos en orden: + +1. **Configuración** : Configure el archivo `config.ini` +2. **Playlists** : Cree sus playlists +3. **Herramientas** : Use los scripts proporcionados +4. **Flash** : Flashee el firmware del ESP32 +5. **MQTT** : Comprenda el funcionamiento de MQTT +6. **Telnet** : Terminal Telnet para pruebas + +--- + +## 1 - ⚙️ Configuración + +El archivo `config.ini` debe colocarse en la raíz de la tarjeta SD. +Permite configurar los siguientes parámetros: + +```ini +# Info +info=0 # 0 = sin info al inicio, 1 = mostrar info al inicio + +# Playlist +playlist=TODO.txt # Lee la playlist indicada en /playlist +random=1 # 0 = reproducción en orden, 1 = reproducción aleatoria + +# Wi-Fi & Bluetooth +wifi_enabled=1 # 0 = Wi-Fi desactivado, 1 = Wi-Fi activado (dejar en 1) +wifi_ssid=mywifi # Nombre de su red Wi-Fi +wifi_password=mypassword # Contraseña Wi-Fi +bluetooth_enabled=0 # 0 = Bluetooth desactivado, 1 = Bluetooth activado (dejar en 0) + # Activar en caso de interferencias (ej: mando 8Bitdo Pro 3) +bluetooth_name=ESP32-GIF # Nombre Bluetooth + +wifi_static_enabled=1 # 0 = DHCP, 1 = IP estática (recomendado) +wifi_static_ip=192.168.20.240 # Solo si wifi_static_enabled=1 +wifi_gateway=192.168.20.1 # Solo si wifi_static_enabled=1 +wifi_subnet=255.255.255.0 # Solo si wifi_static_enabled=1 +wifi_dns1=1.1.1.1 # Solo si wifi_static_enabled=1 +wifi_dns2=8.8.8.8 # Solo si wifi_static_enabled=1 + +# MQTT +recalbox_ip=192.168.20.104 # Dirección IP fija de su Recalbox +``` + +--- + +## 2 - ▶️ Playlists + +Puede crear sus propias playlists. +La herramienta **Generador de Playlists v1.0.1.bat** (modificada desde [RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED)) se encuentra en la carpeta **tools** de este repositorio. +Lista todas las carpetas presentes en el directorio **gifs**. +Si tiene carpetas como `Arcade`, `BEST_OF_TOP_30`, `Pixel_Art`, etc. con GIFs, puede elegir cuáles incluir en su playlist (ej: carpetas 1, 3 y 5). +Para incluir todo en una sola playlist, introduzca `TODO`. + +--- + +## 3 - 🛠️ Herramientas + +Hay un script disponible: + +**`RetroBoxLED_toolkit.py`** +- Extrae las imágenes de sus carpetas de medios +- Convierte las imágenes al formato 128x32 +- Crea la caché de sistemas y juegos +- Copia todo en la tarjeta SD + +La mejor opción es colocar este archivo en una carpeta dedicada para tener todo a mano. +Simplemente siga las instrucciones en pantalla y elija las opciones deseadas. + +La mejor opción para el panel es realizar un scrape completo con Recalbox usando el tipo de imagen **SELECT LOGO TYPE**, ideal para el panel LED, como se muestra en la captura de pantalla a continuación. +Según su elección en **SELECT LOGO TYPE**, deberá volver a hacer un scrape y poner todo de nuevo en la tarjeta SD. +Tengo preferencia por **CLEAR**. + +![Scrapping_Recalbox](medias/Scrapping_Recalbox.png) + +Una vez finalizado el script, encontrará una carpeta `sd_card`. Copie su contenido en su tarjeta SD o consérvela como copia de seguridad. + +Puede descargar los PNGs de los sistemas desde el script o usar los suyos. +También se incluye un sistema llamado **`_defaults`**. Si coloca un archivo `_default.gif` o `_default.png` allí, se usará por defecto cuando no se encuentre ninguna imagen de sistema, así como al inicio. +Por defecto, los GIFs tienen prioridad sobre los PNGs. + +--- + +## 4 - ⚡ Flash + +Antes de comenzar, asegúrese de que su PC reconoce su ESP32. + +## 💡 ¿ESP32 no detectado? + +**Si "Install" no encuentra el puerto COM**: + +| Chip USB | Controladores | +|----------|--------------| +| **CP2102** | [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | +| **CH340/CH341** | [SparkFun](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all) | + +### **[👉 Instalar desde la página web de RETRO PIXEL LED](https://jamyz.github.io/RetroBoxLED/)** + +Pasos de instalación: + +1. Use un navegador compatible (Google Chrome o Microsoft Edge). +2. Conecte su ESP32 al puerto USB de su ordenador. +3. Haga clic en el botón "Install" y seleccione el puerto COM correspondiente. +4. **Importante:** Marque la casilla "Erase device" para realizar un borrado completo de la memoria y evitar errores de fragmentación. + +--- + +## 5 - 🧠 MQTT — El cerebro de RetroBoxLED + +MQTT le indica al ESP32 qué debe mostrar. + +**Recalbox → "Lanzando MAME" → MQTT → ESP32 → "¡Mostrar el GIF o PNG de MAME!"** + +- **Sincronización** : El panel LED muestra exactamente el juego en curso +- **Red local** : 192.168.XXX.XXX (Wi-Fi arcade) + +Ejemplo: +``` +1. Lanza King of Fighters (mame/kof98) +2. El script marquee[rungame,...](permanent).sh detecta el evento → envía "mame/kof98" por MQTT +3. El ESP32 lo recibe → muestra /systems/mame/kof98.gif +4. ¿GIF no encontrado? → muestra /systems/_defaults/_default.gif +``` + +El archivo `marquee[rungame,endgame,systembrowsing,gamelistbrowsing,sleep,wakeup,stop,start](permanent).sh` +debe colocarse en `/recalbox/share/userscripts/` en su Recalbox. + +--- + +## 6 - >_ Telnet + +El firmware incluye un terminal Telnet para probar el ESP32. +Escriba `help` para mostrar la lista de comandos disponibles. +Puede enviar comandos para cambiar los GIFs mostrados, etc. +Esta función se eliminará más adelante, una vez que el código sea estable, para liberar espacio en el ESP32. + +--- + +## 📚 Bibliotecas requeridas + +Para compilar el proyecto desde el IDE de Arduino, instale las siguientes bibliotecas mediante el Gestor de bibliotecas o desde sus repositorios oficiales: + +- **[ESP32-HUB75-MatrixPanel-I2S-DMA](https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA)** : Control de alto rendimiento del panel LED mediante DMA. +- **[AnimatedGIF](https://github.com/bitbank2/AnimatedGIF)** : Decodificador eficiente para leer archivos GIF desde la tarjeta SD. +- **[pngle](https://github.com/kikuchan/pngle)** : Lectura de archivos PNG desde la tarjeta SD. +- **[WiFiManager](https://github.com/tzapu/WiFiManager)** : Gestión de la conexión Wi-Fi mediante un portal cautivo. +- **[Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library)** : Biblioteca base para mostrar texto y formas geométricas. +- **[ArduinoJson](https://github.com/bblanchon/ArduinoJson)** : Gestión de archivos de configuración y comunicación web. + +--- + +## 🛒 Lista de materiales + +Para garantizar la compatibilidad, se recomienda usar los componentes probados durante el desarrollo: + +- **Microcontrolador** : [ESP32 DevKit V1 (38 pines)](https://es.aliexpress.com/item/1005005704190069.html) +- **Panel matricial LED (HUB75)** : [Panel RGB P2.5 / P4](https://es.aliexpress.com/item/1005007439017560.html) +- **Lector de tarjeta** : [Módulo adaptador Micro SD (SPI)](https://es.aliexpress.com/item/1005005591145849.html) +- **Placa de conexión ESP32-Panel** : [DMDos Board V3 - Mortaca](https://www.mortaca.com/) *(Opcional: sin soldadura + lector SD integrado)* +- **Alimentación** : Fuente 5V (mínimo 4A recomendado para paneles 64x32) + +--- + +## 🤝 Créditos + +- [RetroPixelLED original](https://github.com/fjgordillo86/RetroPixelLED) +- [Comunidad Recalbox](https://www.recalbox.com/fr/) +- [Logos de sistemas publicados bajo licencia Creative Commons CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/) +- [Bounitos](https://github.com/BenoitBounar) +- 🎮 [Discord Jamyz](https://discord.com/users/.jamyz) + +--- + +## ☠️ Caídos en combate + +- Una vieja tarjeta SD de 1 GB usada para las pruebas +- 1 ESP32 +- 1 panel LED 64x32 + +--- + +## ☕ Apoyar el proyecto + +Si este proyecto te ha ayudado, puedes invitarme a un café: + +👉 [☕ Donar por PayPal](https://www.paypal.com/paypalme/jamyz77) + +--- + +**RetroBoxLED** = Recalbox + ¡perfección en píxeles LED! 😎 diff --git a/RetroBoxLED_GUItools/README.fr.md b/RetroBoxLED_GUItools/README.fr.md new file mode 100644 index 0000000..43b34ed --- /dev/null +++ b/RetroBoxLED_GUItools/README.fr.md @@ -0,0 +1,241 @@ +# 🎮 RetroBoxLED + +**Firmware ESP32** pour **Recalbox** LED marquee (panneaux HUB75/DMD 128x32 P4). + +✅ Compatible avec Recalbox 10.1-PATREON-1-ALPHA-6.1 + +--- + +🌐 [English](README.md) | [Français](README.fr.md) | [Español](README.es.md) + +--- + +## ℹ️ Info + +J'ai développé par le passé des applications de gestion en C++, C# et VB .Net pour des projets personnels et professionnels. +Mais je dois admettre que l'IA m'a beaucoup aidé à construire ceci en seulement quelques jours. +Ce n'est évidemment pas parfait. Certaines grandes listes comme ARCADE, MAME ou FBNEO mettent longtemps à afficher une image. C'est pourquoi je garde ce projet ouvert à tous, en attendant que quelqu'un fasse mieux. + +## ✨ Fonctionnalités + +- **Lecture de GIFs et PNGs** : Lecture de GIFs et de PNGs (`/Arcade`, `/BEST_OF_TOP_30`, `/Pixel_Art`, etc.) +- **Fallback** : `/systems/_defaults/_default.png` +- **Playlists** : `Arcade.txt`, `Favorites.txt`, `Consoles.txt` +- **MQTT** : Événements EmulationStation (`rungame`, `shutdown`, etc.) +- **Recalbox** : Mode Arcade automatique + +## ⭐ Fonctionnement + +Par défaut, l'ESP32 lit une playlist de GIFs. +Dès qu'il reçoit des informations via MQTT, il bascule automatiquement en mode ARCADE. +Lorsque Recalbox est éteint, l'ESP32 reprend la lecture de la playlist. +Si un GIF ou PNG est manquant, il utilisera le fichier de remplacement placé dans `/systems/_defaults`. + +## 📁 Structure de la carte SD + +La carte SD doit être formatée en FAT32 avec la structure suivante. +Copiez le dossier `_defaults` dans le répertoire `systems` de votre carte SD. + +``` +RetroBoxLED SD Card/ +├── gifs/ +│ ├── Arcade/, BEST_OF_TOP_30/, Pixel_Art/ | GIFs +├── systems/ +│ ├── mame/, neogeo/, snes/ | Systèmes +│ │ ├── jeux.gif ou jeux.png | Image des jeux +│ ├── _defaults/ | Fichiers de remplacement +├── playlists/ +│ ├── Arcade.txt, Favorites.txt, Consoles.txt +``` + +## 🚀 Installation + +Avant utilisation, suivez ces étapes dans l'ordre : + +1. **Configuration** : Paramétrez le fichier `config.ini` +2. **Playlists** : Créez vos playlists +3. **Outils** : Utilisez les scripts fournis +4. **Flash** : Flashez le firmware de l'ESP32 +5. **MQTT** : Comprenez le fonctionnement de MQTT +6. **Telnet** : Terminal Telnet pour les tests + +--- + +## 1 - ⚙️ Configuration + +Le fichier `config.ini` doit être placé à la racine de la carte SD. +Il permet de configurer les paramètres suivants : + +```ini +# Info +info=0 # 0 = pas d'info au démarrage, 1 = afficher les infos au démarrage + +# Playlist +playlist=TODO.txt # Lit la playlist indiquée dans /playlist +random=1 # 0 = lecture dans l'ordre, 1 = lecture aléatoire + +# Wi-Fi & Bluetooth +wifi_enabled=1 # 0 = Wi-Fi désactivé, 1 = Wi-Fi activé (laisser à 1) +wifi_ssid=mywifi # Nom de votre réseau Wi-Fi +wifi_password=mypassword # Mot de passe Wi-Fi +bluetooth_enabled=0 # 0 = Bluetooth désactivé, 1 = Bluetooth activé (laisser à 0) + # Activer en cas d'interférences (ex : manette 8Bitdo Pro 3) +bluetooth_name=ESP32-GIF # Nom Bluetooth + +wifi_static_enabled=1 # 0 = DHCP, 1 = IP statique (recommandé) +wifi_static_ip=192.168.20.240 # Uniquement si wifi_static_enabled=1 +wifi_gateway=192.168.20.1 # Uniquement si wifi_static_enabled=1 +wifi_subnet=255.255.255.0 # Uniquement si wifi_static_enabled=1 +wifi_dns1=1.1.1.1 # Uniquement si wifi_static_enabled=1 +wifi_dns2=8.8.8.8 # Uniquement si wifi_static_enabled=1 + +# MQTT +recalbox_ip=192.168.20.104 # Adresse IP fixe de votre Recalbox +``` + +--- + +## 2 - ▶️ Playlists + +Vous pouvez créer vos propres playlists. +L'outil **Generador de Playlists v1.0.1.bat** (modifié depuis [RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED)) se trouve dans le dossier **tools** de ce dépôt. +Il liste tous les dossiers présents dans le répertoire **gifs**. +Si vous avez des dossiers comme `Arcade`, `BEST_OF_TOP_30`, `Pixel_Art`, etc. contenant des GIFs, vous pouvez choisir lesquels inclure dans votre playlist (ex : dossiers 1, 3 et 5). +Pour tout inclure dans une seule playlist, entrez `TODO`. + +--- + +## 3 - 🛠️ Outils + +Un script est disponible : + +**`RetroBoxLED_toolkit.py`** +- Extrait les images de vos dossiers médias +- Convertit les images au format 128x32 +- Crée le cache des systèmes et des jeux +- Copie tout sur la carte SD + +La meilleure approche est de placer ce fichier dans un dossier dédié pour tout avoir à portée de main. +Suivez simplement les instructions à l'écran et choisissez les options souhaitées. + +La meilleure option pour le panneau est d'effectuer un scrape complet avec Recalbox en utilisant le type d'image **SELECT LOGO TYPE**, idéal pour le panneau LED, comme illustré dans la capture d'écran ci-dessous. +Selon votre choix dans **SELECT LOGO TYPE**, vous devrez refaire un scrape et remettre tout sur la carte SD. +J'ai une préférence pour **CLEAR**. + +![Scrapping_Recalbox](medias/Scrapping_Recalbox.png) + +Une fois le script terminé, vous trouverez un dossier `sd_card`. Copiez son contenu sur votre carte SD ou conservez-le comme sauvegarde. + +Vous pouvez télécharger les PNGs des systèmes depuis le script ou utiliser les vôtres. +Un système nommé **`_defaults`** est également inclus. Si vous y placez un fichier `_default.gif` ou `_default.png`, il sera utilisé par défaut quand aucune image de système n'est trouvée, ainsi qu'au démarrage. +Par défaut, les GIFs ont la priorité sur les PNGs. + +--- + +## 4 - ⚡ Flash + +Avant de commencer, assurez-vous que votre PC reconnaît votre ESP32. + +## 💡 ESP32 non détecté ? + +**Si « Install » ne trouve pas le port COM** : + +| Puce USB | Pilotes | +|----------|---------| +| **CP2102** | [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | +| **CH340/CH341** | [SparkFun](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all) | + +### **[👉 Installer depuis la page web RETRO PIXEL LED](https://jamyz.github.io/RetroBoxLED/)** + +Étapes d'installation : + +1. Utilisez un navigateur compatible (Google Chrome ou Microsoft Edge). +2. Connectez votre ESP32 au port USB de votre ordinateur. +3. Cliquez sur le bouton « Install » et sélectionnez le port COM correspondant. +4. **Important :** Cochez la case « Erase device » pour effectuer un effacement complet de la mémoire et éviter les erreurs de fragmentation. + +--- + +## 5 - 🧠 MQTT — Le cerveau de RetroBoxLED + +MQTT indique à l'ESP32 ce qu'il doit afficher. + +**Recalbox → « Lancement de MAME » → MQTT → ESP32 → « Afficher le GIF ou PNG de MAME ! »** + +- **Synchronisation** : Le panneau LED affiche exactement le jeu en cours +- **Réseau local** : 192.168.XXX.XXX (Wi-Fi arcade) + +Exemple : +``` +1. Vous lancez King of Fighters (mame/kof98) +2. Le script marquee[rungame,...](permanent).sh détecte l'événement → envoie "mame/kof98" via MQTT +3. L'ESP32 le reçoit → affiche /systems/mame/kof98.gif +4. GIF introuvable ? → affiche /systems/_defaults/_default.gif +``` + +Le fichier `marquee[rungame,endgame,systembrowsing,gamelistbrowsing,sleep,wakeup,stop,start](permanent).sh` +doit être placé dans `/recalbox/share/userscripts/` sur votre Recalbox. + +--- + +## 6 - >_ Telnet + +Le firmware inclut un terminal Telnet pour tester l'ESP32. +Tapez `help` pour afficher la liste des commandes disponibles. +Vous pouvez envoyer des commandes pour changer les GIFs affichés, etc. +Cette fonctionnalité sera supprimée ultérieurement, une fois le code stable, afin de libérer de l'espace sur l'ESP32. + +--- + +## 📚 Bibliothèques requises + +Pour compiler le projet depuis l'IDE Arduino, installez les bibliothèques suivantes via le Gestionnaire de bibliothèques ou depuis leurs dépôts officiels : + +- **[ESP32-HUB75-MatrixPanel-I2S-DMA](https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA)** : Contrôle haute performance du panneau LED via DMA. +- **[AnimatedGIF](https://github.com/bitbank2/AnimatedGIF)** : Décodeur efficace pour lire les fichiers GIF depuis la carte SD. +- **[pngle](https://github.com/kikuchan/pngle)** : Lecture de fichiers PNG depuis la carte SD. +- **[WiFiManager](https://github.com/tzapu/WiFiManager)** : Gestion de la connexion Wi-Fi via un portail captif. +- **[Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library)** : Bibliothèque de base pour l'affichage de texte et de formes géométriques. +- **[ArduinoJson](https://github.com/bblanchon/ArduinoJson)** : Gestion des fichiers de configuration et communication web. + +--- + +## 🛒 Liste du matériel + +Pour garantir la compatibilité, il est recommandé d'utiliser les composants testés lors du développement : + +- **Microcontrôleur** : [ESP32 DevKit V1 (38 broches)](https://es.aliexpress.com/item/1005005704190069.html) +- **Panneau matriciel LED (HUB75)** : [Panneau RGB P2.5 / P4](https://es.aliexpress.com/item/1005007439017560.html) +- **Lecteur de carte** : [Module adaptateur Micro SD (SPI)](https://es.aliexpress.com/item/1005005591145849.html) +- **Carte de connexion ESP32-Panneau** : [DMDos Board V3 - Mortaca](https://www.mortaca.com/) *(Optionnel : sans soudure + lecteur SD intégré)* +- **Alimentation** : Source 5V (minimum 4A recommandé pour les panneaux 64x32) + +--- + +## 🤝 Crédits + +- [RetroPixelLED original](https://github.com/fjgordillo86/RetroPixelLED) +- [Communauté Recalbox](https://www.recalbox.com/fr/) +- [Logos systèmes publiés sous licence Creative Commons CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/) +- [Bounitos](https://github.com/BenoitBounar) +- 🎮 [Discord Jamyz](https://discord.com/users/.jamyz) + +--- + +## ☠️ Tombés au combat + +- Une vieille carte SD de 1 Go utilisée pour les tests +- 1 ESP32 +- 1 panneau LED 64x32 + +--- + +## ☕ Soutenir le projet + +Si ce projet vous a aidé, vous pouvez m'offrir un café : + +👉 [☕ Faire un don via PayPal](https://www.paypal.com/paypalme/jamyz77) + +--- + +**RetroBoxLED** = Recalbox + perfection en pixels LED ! 😎 diff --git a/RetroBoxLED_GUItools/README.md b/RetroBoxLED_GUItools/README.md new file mode 100644 index 0000000..5dfa817 --- /dev/null +++ b/RetroBoxLED_GUItools/README.md @@ -0,0 +1,241 @@ +# 🎮 RetroBoxLED + +**ESP32 Firmware** for **Recalbox** LED marquee (HUB75/DMD 128x32 P4 panels). + +✅ Compatible with Recalbox 10.1-PATREON-1-ALPHA-6.1 + +--- + +🌐 [English](README.md) | [Français](README.fr.md) | [Español](README.es.md) + +--- + +## ℹ️ Info + +I previously developed management applications in C++, C# and VB .Net for personal and professional projects. +I have to admit that AI helped me a lot in building this in just a few days. +It is obviously not perfect. Some large lists like ARCADE, MAME or FBNEO take a long time to display an image. That is why I keep this project open to everyone, waiting for someone to do better. + +## ✨ Features + +- **GIF & PNG Playback** : Playback of GIFs and PNGs (`/Arcade`, `/BEST_OF_TOP_30`, `/Pixel_Art`, etc.) +- **Fallback** : `/systems/_defaults/_default.png` +- **Playlists** : `Arcade.txt`, `Favorites.txt`, `Consoles.txt` +- **MQTT** : EmulationStation events (`rungame`, `shutdown`, etc.) +- **Recalbox** : Automatic Arcade mode + +## ⭐ How it works + +By default, the ESP32 plays a GIF playlist. +As soon as it receives information via MQTT, it automatically switches to ARCADE mode. +When Recalbox is turned off, the ESP32 resumes playlist playback. +If a GIF or PNG is missing, it will use the fallback file placed in `/systems/_defaults`. + +## 📁 SD Card Structure + +The SD card must be formatted in FAT32 with the following structure. +Copy the `_defaults` folder into the `systems` directory of your SD card. + +``` +RetroBoxLED SD Card/ +├── gifs/ +│ ├── Arcade/, BEST_OF_TOP_30/, Pixel_Art/ | GIFs +├── systems/ +│ ├── mame/, neogeo/, snes/ | Systems +│ │ ├── game.gif or game.png | Game images +│ ├── _defaults/ | Fallback files +├── playlists/ +│ ├── Arcade.txt, Favorites.txt, Consoles.txt +``` + +## 🚀 Installation + +Before use, follow these steps in order: + +1. **Configuration** : Set up the `config.ini` file +2. **Playlists** : Create your playlists +3. **Tools** : Use the provided scripts +4. **Flash** : Flash the ESP32 firmware +5. **MQTT** : Understand how MQTT works +6. **Telnet** : Telnet terminal for testing + +--- + +## 1 - ⚙️ Configuration + +The `config.ini` file must be placed at the root of the SD card. +It allows you to configure the following parameters: + +```ini +# Info +info=0 # 0 = no info at startup, 1 = display info at startup + +# Playlist +playlist=TODO.txt # Reads the playlist indicated in /playlist +random=1 # 0 = sequential playback, 1 = random playback + +# Wi-Fi & Bluetooth +wifi_enabled=1 # 0 = Wi-Fi disabled, 1 = Wi-Fi enabled (leave at 1) +wifi_ssid=mywifi # Your Wi-Fi network name +wifi_password=mypassword # Wi-Fi password +bluetooth_enabled=0 # 0 = Bluetooth disabled, 1 = Bluetooth enabled (leave at 0) + # Enable in case of interference (e.g. 8Bitdo Pro 3 controller) +bluetooth_name=ESP32-GIF # Bluetooth name + +wifi_static_enabled=1 # 0 = DHCP, 1 = static IP (recommended) +wifi_static_ip=192.168.20.240 # Only if wifi_static_enabled=1 +wifi_gateway=192.168.20.1 # Only if wifi_static_enabled=1 +wifi_subnet=255.255.255.0 # Only if wifi_static_enabled=1 +wifi_dns1=1.1.1.1 # Only if wifi_static_enabled=1 +wifi_dns2=8.8.8.8 # Only if wifi_static_enabled=1 + +# MQTT +recalbox_ip=192.168.20.104 # Fixed IP address of your Recalbox +``` + +--- + +## 2 - ▶️ Playlists + +You can create your own playlists. +The **Generador de Playlists v1.0.1.bat** tool (modified from [RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED)) is located in the **tools** folder of this repository. +It lists all folders present in the **gifs** directory. +If you have folders like `Arcade`, `BEST_OF_TOP_30`, `Pixel_Art`, etc. containing GIFs, you can choose which ones to include in your playlist (e.g. folders 1, 3 and 5). +To include everything in a single playlist, enter `TODO`. + +--- + +## 3 - 🛠️ Tools + +A script is available: + +**`RetroBoxLED_toolkit.py`** +- Extracts images from your media folders +- Converts images to 128x32 format +- Creates the system and game cache +- Copies everything to the SD card + +The best approach is to place this file in a dedicated folder to have everything at hand. +Simply follow the on-screen instructions and choose the desired options. + +The best option for the panel is to perform a full scrape with Recalbox using the **SELECT LOGO TYPE** image type, ideal for the LED panel, as shown in the screenshot below. +Depending on your choice in **SELECT LOGO TYPE**, you will need to redo a scrape and put everything back on the SD card. +I have a preference for **CLEAR**. + +![Scrapping_Recalbox](medias/Scrapping_Recalbox.png) + +Once the script is complete, you will find an `sd_card` folder. Copy its contents to your SD card or keep it as a backup. + +You can download system PNGs from the script or use your own. +A system named **`_defaults`** is also included. If you place a `_default.gif` or `_default.png` file there, it will be used by default when no system image is found, as well as at startup. +By default, GIFs take priority over PNGs. + +--- + +## 4 - ⚡ Flash + +Before starting, make sure your PC recognizes your ESP32. + +## 💡 ESP32 not detected? + +**If "Install" cannot find the COM port**: + +| USB Chip | Drivers | +|----------|---------| +| **CP2102** | [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | +| **CH340/CH341** | [SparkFun](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all) | + +### **[👉 Install from the RETRO PIXEL LED web page](https://jamyz.github.io/RetroBoxLED/)** + +Installation steps: + +1. Use a compatible browser (Google Chrome or Microsoft Edge). +2. Connect your ESP32 to your computer's USB port. +3. Click the "Install" button and select the corresponding COM port. +4. **Important:** Check the "Erase device" box to perform a full memory erase and avoid fragmentation errors. + +--- + +## 5 - 🧠 MQTT — The brain of RetroBoxLED + +MQTT tells the ESP32 what to display. + +**Recalbox → "Launching MAME" → MQTT → ESP32 → "Display the MAME GIF or PNG!"** + +- **Synchronization** : The LED panel displays exactly the game being played +- **Local network** : 192.168.XXX.XXX (arcade Wi-Fi) + +Example: +``` +1. You launch King of Fighters (mame/kof98) +2. The marquee[rungame,...](permanent).sh script detects the event → sends "mame/kof98" via MQTT +3. The ESP32 receives it → displays /systems/mame/kof98.gif +4. GIF not found? → displays /systems/_defaults/_default.gif +``` + +The file `marquee[rungame,endgame,systembrowsing,gamelistbrowsing,sleep,wakeup,stop,start](permanent).sh` +must be placed in `/recalbox/share/userscripts/` on your Recalbox. + +--- + +## 6 - >_ Telnet + +The firmware includes a Telnet terminal for testing the ESP32. +Type `help` to display the list of available commands. +You can send commands to change the GIFs displayed, etc. +This feature will be removed later, once the code is stable, in order to free up space on the ESP32. + +--- + +## 📚 Required Libraries + +To compile the project from the Arduino IDE, install the following libraries via the Library Manager or from their official repositories: + +- **[ESP32-HUB75-MatrixPanel-I2S-DMA](https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA)** : High-performance LED panel control via DMA. +- **[AnimatedGIF](https://github.com/bitbank2/AnimatedGIF)** : Efficient decoder for reading GIF files from the SD card. +- **[pngle](https://github.com/kikuchan/pngle)** : Reading PNG files from the SD card. +- **[WiFiManager](https://github.com/tzapu/WiFiManager)** : Wi-Fi connection management via a captive portal. +- **[Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library)** : Base library for displaying text and geometric shapes. +- **[ArduinoJson](https://github.com/bblanchon/ArduinoJson)** : Configuration file management and web communication. + +--- + +## 🛒 Hardware List + +To ensure compatibility, it is recommended to use the components tested during development: + +- **Microcontroller** : [ESP32 DevKit V1 (38 pins)](https://es.aliexpress.com/item/1005005704190069.html) +- **LED Matrix Panel (HUB75)** : [RGB Panel P2.5 / P4](https://es.aliexpress.com/item/1005007439017560.html) +- **Card Reader** : [Micro SD Adapter Module (SPI)](https://es.aliexpress.com/item/1005005591145849.html) +- **ESP32-Panel Connection Board** : [DMDos Board V3 - Mortaca](https://www.mortaca.com/) *(Optional: solderless + integrated SD reader)* +- **Power Supply** : 5V source (minimum 4A recommended for 64x32 panels) + +--- + +## 🤝 Credits + +- [Original RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED) +- [Recalbox Community](https://www.recalbox.com/fr/) +- [System logos published under Creative Commons CC BY-NC-ND 4.0 license](https://creativecommons.org/licenses/by-nc-nd/4.0/) +- [Bounitos](https://github.com/BenoitBounar) +- 🎮 [Discord Jamyz](https://discord.com/users/.jamyz) + +--- + +## ☠️ Fallen in battle + +- An old 1 GB SD card used for testing +- 1 ESP32 +- 1 64x32 LED panel + +--- + +## ☕ Support the project + +If this project helped you, you can buy me a coffee: + +👉 [☕ Donate via PayPal](https://www.paypal.com/paypalme/jamyz77) + +--- + +**RetroBoxLED** = Recalbox + LED pixel perfection! 😎 diff --git a/RetroBoxLED_GUItools/RetroBoxLED_GUItools.py b/RetroBoxLED_GUItools/RetroBoxLED_GUItools.py new file mode 100644 index 0000000..0056212 --- /dev/null +++ b/RetroBoxLED_GUItools/RetroBoxLED_GUItools.py @@ -0,0 +1,2266 @@ +#!/usr/bin/env python3 +""" +recalbox_toolkit.py — Unified tool for ESP32 Marquee +===================================================== +1. Gamelist extraction + 128x32 conversion + build cache +2. Gamelist extraction only +3. 128x32 conversion only +4. Build games_cache only +""" + +import os +import re +import sys +import shutil +import struct +import time +import threading +import tempfile +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path +from urllib.parse import unquote + +# ───────────────────────────────────────────────────────────────────────────── +# PAUSE CONTROLLER +# ───────────────────────────────────────────────────────────────────────────── + + +class PauseController: + """ + Écoute la touche P en arrière-plan pendant un traitement. + États : RUNNING / PAUSED / SKIP / STOP + """ + + RUNNING = "running" + PAUSED = "paused" + SKIP = "skip" + STOP = "stop" + + def __init__(self): + self.state = self.RUNNING + self._lock = threading.Lock() + self._thread = None + self._active = False + + def start(self, listen_keyboard: bool = True): + self.state = self.RUNNING + self._active = True + self._thread = None + if listen_keyboard: + self._thread = threading.Thread(target=self._listen, daemon=True) + self._thread.start() + + def stop(self): + self._active = False + + def request_pause(self): + with self._lock: + if self.state == self.RUNNING: + self.state = self.PAUSED + + def request_resume(self): + with self._lock: + if self.state in (self.PAUSED, self.SKIP): + self.state = self.RUNNING + + def request_skip(self): + with self._lock: + if self.state in (self.RUNNING, self.PAUSED): + self.state = self.SKIP + + def request_stop(self): + with self._lock: + if self.state in (self.RUNNING, self.PAUSED, self.SKIP): + self.state = self.STOP + + def _listen(self): + """Thread background : lit stdin ligne par ligne.""" + import sys + + while self._active: + try: + # On lit stdin sans bloquer le thread principal + # msvcrt sur Windows, sinon select sur Unix + if sys.platform == "win32": + import msvcrt + + if msvcrt.kbhit(): + ch = msvcrt.getwch() + if ord(ch) == 27: # ESC + self._on_pause() + time.sleep(0.1) + else: + import select + + if select.select([sys.stdin], [], [], 0.1)[0]: + ch = sys.stdin.read(1) + if ord(ch) == 27: # ESC + self._on_pause() + except Exception: + time.sleep(0.1) + + def _on_pause(self): + with self._lock: + if self.state != self.RUNNING: + return + self.state = self.PAUSED + + print(tr("pause_title")) + sep("─") + print(f" 1 → {tr('pause_opt1')}") + print(f" 2 → {tr('pause_opt2')}") + print(f" 3 → {tr('pause_opt3')}") + print() + + while True: + raw = input(tr("pause_choice")).strip() + if raw == "1": + print(tr("pause_resuming")) + with self._lock: + self.state = self.RUNNING + break + elif raw == "2": + print(tr("pause_skipping")) + with self._lock: + self.state = self.SKIP + break + elif raw == "3": + print(tr("pause_stopping")) + with self._lock: + self.state = self.STOP + break + else: + print(tr("pause_warn")) + + def is_running(self): + with self._lock: + return self.state == self.RUNNING + + def should_skip(self): + with self._lock: + return self.state == self.SKIP + + def should_stop(self): + with self._lock: + return self.state == self.STOP + + def wait_if_paused(self): + """Attend tant que l'état est PAUSED (bloque le thread principal).""" + while True: + with self._lock: + if self.state != self.PAUSED: + break + time.sleep(0.1) + + +# Global pause controller +PAUSE = PauseController() + +# ───────────────────────────────────────────────────────────────────────────── +# TRANSLATIONS +# ───────────────────────────────────────────────────────────────────────────── + +TRANSLATIONS = { + "fr": { + "pillow_installing": "⚙️ Pillow n'est pas installé. Installation automatique en cours...", + "pillow_ok": "✅ Pillow installé avec succès !\n", + "pillow_fail": "❌ Impossible d'installer Pillow.\n Lance manuellement : pip install Pillow\n La conversion 128x32 sera désactivée.\n", + "main_title": "RetroBoxLED Toolkit for Recalbox", + "dl_title": "🌐 DOSSIER _defaults (images systèmes)", + "dl_missing": " ℹ️ Aucun dossier _defaults/ trouvé dans sd_card/systems/.", + "dl_exists": " ℹ️ Le dossier _defaults/ existe déjà dans sd_card/systems/.", + "dl_ask_download": "Télécharger _defaults/ depuis GitHub (RetroBoxLED) ?", + "dl_ask_update": "Mettre à jour _defaults/ depuis GitHub (RetroBoxLED) ?", + "dl_skip": " ⏭️ Téléchargement ignoré.", + "dl_starting": "⬇️ Téléchargement des fichiers depuis GitHub...", + "dl_file_ok": lambda n, i, t: f" {i:4d}/{t} ✅ {n}", + "dl_file_err": lambda n, e: f" ⚠️ {n} — {e}", + "dl_done": lambda n: f"✅ {n} fichiers téléchargés dans _defaults/", + "dl_fail_api": "❌ API GitHub inaccessible. Vérifiez votre connexion internet.", + "dl_replacing": "🗑️ Remplacement du _defaults/ existant...", + "main_prompt": "Que voulez-vous faire ?", + "main_opt1": "Extraction gamelist + Conversion 128x32 + Build cache (TOUT)", + "main_opt2": "Seulement extraire les images des gamelists", + "main_opt3": "Seulement convertir des images en 128x32", + "main_opt4": "Seulement construire le games_cache.bin", + "main_opt7": "Interface graphique (Tkinter)", + "main_choice": "Votre choix (1-7) : ", + "main_opt_quit": "Quitter", + "main_warn": "⚠️ Tape un chiffre entre 0 et 7.\n", + "back": "↩ Retour en arrière", + "back_main": "\n ↩ Retour au menu principal...", + "back_roms": "\n ↩ Retour au choix du dossier roms...", + "yes_no": "(o/n)", + "yes_vals": ("o", "oui", "y", "yes"), + "no_vals": ("n", "non", "no"), + "warn_yn": "⚠️ Tape o ou n.\n", + "warn_choice": "⚠️ Tape 0, 1 ou 2.\n", + "after_menu": "Que voulez-vous faire ensuite ?", + "after_opt1": "Retour au menu principal", + "after_opt6": "Copier sur la carte SD maintenant (mode 6)", + "after_opt_files": "Copier les fichiers générés sur la carte SD", + "press_enter": "Appuie sur Entrée pour fermer...", + "press_enter_cont": "Appuie sur Entrée pour continuer quand même...", + "path_local": " 1 → Lecteur local (ex: D:\\Recalbox\\roms)", + "path_network": " 2 → Réseau / NAS (ex: \\\\192.168.1.1\\share\\roms)", + "path_choice": "Votre choix (0, 1 ou 2) : ", + "path_local_lbl": "Chemin du dossier (0 pour revenir) : ", + "path_net_lbl": "Chemin réseau (0 pour revenir) : ", + "path_not_found": "❌ Dossier introuvable. Vérifie le chemin et réessaie.\n", + "sd_erase_ask": "Voulez-vous l'effacer complètement avant de continuer ?", + "sd_erased": "🗑️ Dossier effacé et recréé.", + "sd_erase_err": "❌ Erreur lors de l'effacement. Ferme les fichiers ouverts dans sd_card/ et réessaie.", + "sd_kept": " ℹ️ Contenu conservé, les fichiers existants seront écrasés ou ignorés.", + "ext_title": "🚀 ÉTAPE 1/3 — EXTRACTION DES IMAGES", + "ext_roms_where": "\n📍 OÙ SE TROUVENT VOS ROMS ?", + "ext_systems": "systèmes avec gamelist.xml détectés", + "ext_games_found": "jeux trouvés", + "ext_xml_err": "ERREUR XML", + "ext_already": "déjà présente", + "ext_missing_tag": "MANQUANT", + "ext_summary_copied": "copiées", + "ext_summary_skip": "déjà présentes", + "ext_summary_miss": "manquantes", + "ext_log_header": "=== IMAGES MANQUANTES ===\n", + "ext_log_source": "Source : ", + "ext_log_summary": "=== RÉSUMÉ ===\n", + "ext_log_games": "Jeux parcourus : ", + "ext_log_copied": "Images copiées : ", + "ext_log_skipped": "Déjà présentes : ", + "ext_log_missing": "Images manquantes : ", + "conv_title": "🖼️ ÉTAPE 2/3 — CONVERSION 128x32", + "conv_png_only": "\n⚠️ INFO : Seuls les fichiers PNG seront convertis en 128x32.\n Les GIF sont conservés tels quels.\n", + "conv_gif_info": "GIF trouvés → conservés sans modification.", + "conv_png_count": "PNG à convertir en 128x32", + "conv_summary_done": "PNG convertis", + "conv_summary_err": "erreurs", + "conv_summary_gif": "GIF conservés", + "conv_no_pillow": "❌ Pillow n'est pas installé. Installe-le avec : pip install Pillow", + "conv_src_where": "\n📍 OÙ SE TROUVENT LES IMAGES À CONVERTIR ?\n (dossier systems/ contenant les sous-dossiers par système)", + "cache_title": "💾 ÉTAPE 3/3 — BUILD GAMES CACHE", + "cache_scan": "[INFO] Scan de : ", + "cache_found_sys": "systèmes,", + "cache_found_games": "jeux trouvés", + "cache_no_sys": "⚠️ Aucun système trouvé. Vérifiez le dossier systems/.", + "cache_size": "Ko", + "cache_sys_where": "\n📍 OÙ SE TROUVE LE DOSSIER SYSTEMS ?", + "cache_sys_detected": "✅ Dossier systems/ détecté : ", + "cache_sys_use": "Utiliser ce dossier ?", + "cache_sys_missing": "⚠️ Aucun dossier systems/ trouvé dans ", + "mode1_title": "MODE 1 — Extraction + Conversion 128x32 + Build Cache", + "mode2_title": "MODE 2 — Extraction Gamelist uniquement", + "mode3_title": "MODE 3 — Conversion 128x32 uniquement", + "mode4_title": "MODE 4 — Build Games Cache uniquement", + "done": "🎉 TERMINÉ !", + "done_sd": "📂 Dossier SD card : ", + "done_cache": "💾 Cache : ", + "done_log": "📋 Log images manquantes : ", + "done_copy_sd": "Copiez le contenu de ce dossier à la racine de votre carte SD.", + "done_extracted": "📂 Images extraites dans : ", + "done_log2": "📋 Log : ", + "done_converted": "📂 Images converties dans : ", + "done_cache2": "💾 Cache généré : ", + "done_copy_cache": "Copiez ces fichiers à la racine de votre carte SD.", + "done_cache_files": "💾 Fichiers générés :", + "src_ok": "✅ Dossier : ", + "roms_ok": "✅ Dossier ROMs : ", + "sysc_title": "MODE 5 — Génération de systems_cache.dat", + "sysc_no_defaults": "⚠️ Aucun dossier _defaults/ trouvé dans systems/.", + "sysc_found": lambda n: f" 📂 {n} systèmes trouvés dans _defaults/", + "sysc_line": lambda t, n: f" {'✅' if t != '?' else '⚠️ '} {t} {n}", + "sysc_unknown": "⚠️ Pas de .gif ni .png trouvé pour ce système.", + "sysc_done": lambda n, p: f"✅ {n} systèmes écrits dans {p}", + "sysc_copy": "Copiez systems_cache.dat à la racine de votre carte SD.", + "sysc_hint": " (L'ESP32 l'utilisera au prochain démarrage sans rescanner)", + "flash_title": "MODE 6 — Copier sur la carte SD", + "flash_no_sdcard": "⚠️ Le dossier sd_card/ est vide ou absent. Lancez un autre mode d'abord.", + "flash_no_win": "⚠️ Ce mode est uniquement disponible sur Windows.", + "flash_admin_warn": "⚠️ Droits administrateur requis. Relancez le script en tant qu'Administrateur.", + "flash_drives_title": "\n💾 LECTEURS DISPONIBLES (amovibles / carte SD) :", + "flash_no_drives": "⚠️ Aucun lecteur amovible détecté. Insérez votre carte SD et réessayez.", + "flash_drive_choice": "Choisissez le lecteur de destination (0 pour revenir) : ", + "flash_drive_warn": "⚠️ Choix invalide.\n", + "flash_drive_sel": lambda d, s: f"\n✅ Destination : {d} ({s})", + "flash_mode_title": "\n⚙️ MODE DE COPIE", + "flash_mode_opt1": "Formater en FAT32 puis copier (ATTENTION : efface tout sur la SD)", + "flash_mode_opt2": "Copier uniquement — écraser les fichiers existants", + "flash_mode_opt3": "Copier uniquement — ignorer les fichiers existants (garder ce qui est déjà là)", + "flash_mode_choice": "Votre choix (0-2) : ", + "flash_mode_warn": "⚠️ Tape 0, 1 ou 2.\n", + "flash_fmt_confirm": lambda d: f"⚠️ TOUTES LES DONNÉES sur {d} seront effacées. Êtes-vous sûr ?", + "flash_fmt_abort": " ↩ Formatage annulé.", + "flash_fmt_start": lambda d: f"🗑️ Formatage de {d} en FAT32...", + "flash_fmt_ok": "✅ Formatage terminé.", + "flash_fmt_err": lambda e: f"❌ Erreur de formatage : {e}", + "flash_copy_start": lambda s, d: f"\n📋 Copie de {s} → {d} (robocopy /MT:32)...", + "flash_copy_ok": "✅ Copie terminée.", + "flash_copy_err": lambda c: f"⚠️ Robocopy terminé avec le code {c} (vérifiez la sortie ci-dessus).", + "main_opt6": "Copier sd_card/ sur la carte SD (rapide, robocopy)", + "main_opt5": "Générer systems_cache.dat (index systèmes ESP32)", + "pause_hint": " ⏸️ Appuie sur [ESC] pour mettre en pause", + "pause_title": "\n⏸️ PAUSE", + "pause_opt1": "Continuer", + "pause_opt2": "Passer à l'étape suivante", + "pause_opt3": "Arrêter le script", + "pause_choice": "Votre choix (1-3) : ", + "pause_warn": "⚠️ Tape 1, 2 ou 3.\n", + "pause_resuming": "▶️ Reprise...", + "pause_skipping": "⏭️ Passage à l'étape suivante...", + "pause_stopping": "🛑 Arrêt demandé.", + "sys_sel_title": "🎮 SYSTÈMES DÉTECTÉS", + "sys_sel_none": "⚠️ Aucun système avec gamelist.xml trouvé dans ce dossier.", + "sys_sel_prompt": "Quels systèmes traiter ?", + "sys_sel_opt_all": "Tous les systèmes", + "sys_sel_opt_pick": "Choisir les systèmes à traiter", + "sys_sel_pick_hint": "Entrez les numéros séparés par des virgules (ex: 1,3,5) ou 0 pour tout sélectionner :", + "sys_sel_warn": "⚠️ Sélection invalide. Réessayez.\n", + "sys_sel_selected": lambda n: f"✅ {n} système(s) sélectionné(s).", + }, + "en": { + "pillow_installing": "⚙️ Pillow is not installed. Installing automatically...", + "pillow_ok": "✅ Pillow installed successfully!\n", + "pillow_fail": "❌ Could not install Pillow.\n Run manually: pip install Pillow\n 128x32 conversion will be disabled.\n", + "main_title": "RetroBoxLED Toolkit for Recalbox", + "dl_title": "🌐 _defaults FOLDER (system images)", + "dl_missing": " ℹ️ No _defaults/ folder found in sd_card/systems/.", + "dl_exists": " ℹ️ _defaults/ already exists in sd_card/systems/.", + "dl_ask_download": "Download _defaults/ from GitHub (RetroBoxLED)?", + "dl_ask_update": "Update _defaults/ from GitHub (RetroBoxLED)?", + "dl_skip": " ⏭️ Download skipped.", + "dl_starting": "⬇️ Downloading files from GitHub...", + "dl_file_ok": lambda n, i, t: f" {i:4d}/{t} ✅ {n}", + "dl_file_err": lambda n, e: f" ⚠️ {n} — {e}", + "dl_done": lambda n: f"✅ {n} files downloaded into _defaults/", + "dl_fail_api": "❌ GitHub API unreachable. Check your internet connection.", + "dl_replacing": "🗑️ Replacing existing _defaults/...", + "main_prompt": "What do you want to do?", + "main_opt1": "Gamelist extraction + 128x32 conversion + Build cache (ALL)", + "main_opt2": "Gamelist image extraction only", + "main_opt3": "128x32 conversion only", + "main_opt4": "Build games_cache.bin only", + "main_opt7": "Graphical interface (Tkinter)", + "main_choice": "Your choice (1-7): ", + "main_opt_quit": "Quit", + "main_warn": "⚠️ Enter a number between 0 and 7.\n", + "back": "↩ Go back", + "back_main": "\n ↩ Back to main menu...", + "back_roms": "\n ↩ Back to ROMs folder selection...", + "yes_no": "(y/n)", + "yes_vals": ("y", "yes", "o", "oui"), + "no_vals": ("n", "no", "non"), + "warn_yn": "⚠️ Type y or n.\n", + "warn_choice": "⚠️ Type 0, 1 or 2.\n", + "after_menu": "What do you want to do next?", + "after_opt1": "Back to main menu", + "after_opt6": "Copy to SD card now (mode 6)", + "after_opt_files": "Copy generated files to SD card", + "press_enter": "Press Enter to close...", + "press_enter_cont": "Press Enter to continue anyway...", + "path_local": " 1 → Local drive (e.g.: D:\\Recalbox\\roms)", + "path_network": " 2 → Network / NAS (e.g.: \\\\192.168.1.1\\share\\roms)", + "path_choice": "Your choice (0, 1 or 2): ", + "path_local_lbl": "Folder path (0 to go back): ", + "path_net_lbl": "Network path (0 to go back): ", + "path_not_found": "❌ Folder not found. Check the path and try again.\n", + "sd_erase_ask": "Do you want to completely erase it before continuing?", + "sd_erased": "🗑️ Folder erased and recreated.", + "sd_erase_err": "❌ Error while erasing. Close any open files in sd_card/ and try again.", + "sd_kept": " ℹ️ Content kept, existing files will be overwritten or skipped.", + "ext_title": "🚀 STEP 1/3 — IMAGE EXTRACTION", + "ext_roms_where": "\n📍 WHERE ARE YOUR ROMS?", + "ext_systems": "systems with gamelist.xml detected", + "ext_games_found": "games found", + "ext_xml_err": "XML ERROR", + "ext_already": "already exists", + "ext_missing_tag": "MISSING", + "ext_summary_copied": "copied", + "ext_summary_skip": "already present", + "ext_summary_miss": "missing", + "ext_log_header": "=== MISSING IMAGES ===\n", + "ext_log_source": "Source: ", + "ext_log_summary": "=== SUMMARY ===\n", + "ext_log_games": "Games scanned : ", + "ext_log_copied": "Images copied : ", + "ext_log_skipped": "Already present : ", + "ext_log_missing": "Missing images : ", + "conv_title": "🖼️ STEP 2/3 — 128x32 CONVERSION", + "conv_png_only": "\n⚠️ INFO: Only PNG files will be converted to 128x32.\n GIFs are kept as-is.\n", + "conv_gif_info": "GIF found → kept without modification.", + "conv_png_count": "PNG to convert to 128x32", + "conv_summary_done": "PNG converted", + "conv_summary_err": "errors", + "conv_summary_gif": "GIF kept", + "conv_no_pillow": "❌ Pillow is not installed. Install it with: pip install Pillow", + "conv_src_where": "\n📍 WHERE ARE THE IMAGES TO CONVERT?\n (systems/ folder containing subfolders per system)", + "cache_title": "💾 STEP 3/3 — BUILD GAMES CACHE", + "cache_scan": "[INFO] Scanning: ", + "cache_found_sys": "systems,", + "cache_found_games": "games found", + "cache_no_sys": "⚠️ No systems found. Check the systems/ folder.", + "cache_size": "KB", + "cache_sys_where": "\n📍 WHERE IS THE SYSTEMS FOLDER?", + "cache_sys_detected": "✅ systems/ folder detected: ", + "cache_sys_use": "Use this folder?", + "cache_sys_missing": "⚠️ No systems/ folder found in ", + "mode1_title": "MODE 1 — Extraction + 128x32 Conversion + Build Cache", + "mode2_title": "MODE 2 — Gamelist Extraction only", + "mode3_title": "MODE 3 — 128x32 Conversion only", + "mode4_title": "MODE 4 — Build Games Cache only", + "done": "🎉 DONE!", + "done_sd": "📂 SD card folder : ", + "done_cache": "💾 Cache : ", + "done_log": "📋 Missing images log : ", + "done_copy_sd": "Copy the contents of this folder to the root of your SD card.", + "done_extracted": "📂 Images extracted to: ", + "done_log2": "📋 Log: ", + "done_converted": "📂 Images converted in: ", + "done_cache2": "💾 Cache generated: ", + "done_copy_cache": "Copy these files to the root of your SD card.", + "done_cache_files": "💾 Generated files:", + "src_ok": "✅ Folder: ", + "roms_ok": "✅ ROMs folder: ", + "sysc_title": "MODE 5 — Build systems_cache.dat", + "sysc_no_defaults": "⚠️ No _defaults/ folder found in systems/.", + "sysc_found": lambda n: f" 📂 {n} systems found in _defaults/", + "sysc_line": lambda t, n: f" {'✅' if t != '?' else '⚠️ '} {t} {n}", + "sysc_unknown": "⚠️ No .gif or .png found for this system.", + "sysc_done": lambda n, p: f"✅ {n} systems written to {p}", + "sysc_copy": "Copy systems_cache.dat to the root of your SD card.", + "sysc_hint": " (The ESP32 will use this on next boot instead of rescanning)", + "flash_title": "MODE 6 — Copy to SD card", + "flash_no_sdcard": "⚠️ sd_card/ folder is empty or missing. Run another mode first.", + "flash_no_win": "⚠️ This mode is only available on Windows.", + "flash_admin_warn": "⚠️ Administrator rights required. Please relaunch as Administrator.", + "flash_drives_title": "\n💾 AVAILABLE DRIVES (removable / SD card) :", + "flash_no_drives": "⚠️ No removable drive detected. Insert your SD card and try again.", + "flash_drive_choice": "Choose destination drive (0 to go back): ", + "flash_drive_warn": "⚠️ Invalid choice.\n", + "flash_drive_sel": lambda d, s: f"\n✅ Destination: {d} ({s})", + "flash_mode_title": "\n⚙️ COPY MODE", + "flash_mode_opt1": "Format FAT32 then copy (WARNING: erases everything on the SD)", + "flash_mode_opt2": "Copy only — overwrite existing files", + "flash_mode_opt3": "Copy only — skip existing files (keep what's already there)", + "flash_mode_choice": "Your choice (0-2): ", + "flash_mode_warn": "⚠️ Type 0, 1 or 2.\n", + "flash_fmt_confirm": lambda d: f"⚠️ ALL DATA on {d} will be erased. Are you sure?", + "flash_fmt_abort": " ↩ Format cancelled.", + "flash_fmt_start": lambda d: f"🗑️ Formatting {d} in FAT32...", + "flash_fmt_ok": "✅ Format complete.", + "flash_fmt_err": lambda e: f"❌ Format error: {e}", + "flash_copy_start": lambda s, d: f"\n📋 Copying {s} → {d} (robocopy /MT:32)...", + "flash_copy_ok": "✅ Copy complete.", + "flash_copy_err": lambda c: f"⚠️ Robocopy finished with code {c} (check output above).", + "main_opt6": "Copy sd_card/ to SD card (fast, robocopy)", + "main_opt5": "Build systems_cache.dat (ESP32 system index)", + "pause_hint": " ⏸️ Press [ESC] to pause", + "pause_title": "\n⏸️ PAUSED", + "pause_opt1": "Continue", + "pause_opt2": "Skip to next step", + "pause_opt3": "Stop the script", + "pause_choice": "Your choice (1-3): ", + "pause_warn": "⚠️ Type 1, 2 or 3.\n", + "pause_resuming": "▶️ Resuming...", + "pause_skipping": "⏭️ Skipping to next step...", + "pause_stopping": "🛑 Stop requested.", + "sys_sel_title": "🎮 DETECTED SYSTEMS", + "sys_sel_none": "⚠️ No system with gamelist.xml found in this folder.", + "sys_sel_prompt": "Which systems to process?", + "sys_sel_opt_all": "All systems", + "sys_sel_opt_pick": "Choose specific systems", + "sys_sel_pick_hint": "Enter numbers separated by commas (e.g. 1,3,5) or 0 to select all:", + "sys_sel_warn": "⚠️ Invalid selection. Try again.\n", + "sys_sel_selected": lambda n: f"✅ {n} system(s) selected.", + }, + "es": { + "pillow_installing": "⚙️ Pillow no está instalado. Instalando automáticamente...", + "pillow_ok": "✅ ¡Pillow instalado correctamente!\n", + "pillow_fail": "❌ No se pudo instalar Pillow.\n Ejecútalo manualmente: pip install Pillow\n La conversión 128x32 estará desactivada.\n", + "main_title": "RetroBoxLED Toolkit for Recalbox", + "dl_title": "🌐 CARPETA _defaults (imágenes de sistemas)", + "dl_missing": " ℹ️ No se encontró carpeta _defaults/ en sd_card/systems/.", + "dl_exists": " ℹ️ La carpeta _defaults/ ya existe en sd_card/systems/.", + "dl_ask_download": "¿Descargar _defaults/ desde GitHub (RetroBoxLED)?", + "dl_ask_update": "¿Actualizar _defaults/ desde GitHub (RetroBoxLED)?", + "dl_skip": " ⏭️ Descarga omitida.", + "dl_starting": "⬇️ Descargando archivos desde GitHub...", + "dl_file_ok": lambda n, i, t: f" {i:4d}/{t} ✅ {n}", + "dl_file_err": lambda n, e: f" ⚠️ {n} — {e}", + "dl_done": lambda n: f"✅ {n} archivos descargados en _defaults/", + "dl_fail_api": "❌ API de GitHub inaccesible. Verifica tu conexión a internet.", + "dl_replacing": "🗑️ Reemplazando _defaults/ existente...", + "main_prompt": "¿Qué desea hacer?", + "main_opt1": "Extracción gamelist + Conversión 128x32 + Build cache (TODO)", + "main_opt2": "Solo extraer imágenes de los gamelists", + "main_opt3": "Solo convertir imágenes a 128x32", + "main_opt4": "Solo construir games_cache.bin", + "main_opt7": "Interfaz gráfica (Tkinter)", + "main_choice": "Su eleccion (1-7): ", + "main_opt_quit": "Salir", + "main_warn": "⚠️ Escribe un número entre 0 y 7.\n", + "back": "↩ Volver atrás", + "back_main": "\n ↩ Volver al menú principal...", + "back_roms": "\n ↩ Volver a la selección de carpeta ROMs...", + "yes_no": "(s/n)", + "yes_vals": ("s", "si", "sí", "y", "yes", "o", "oui"), + "no_vals": ("n", "no", "non"), + "warn_yn": "⚠️ Escribe s o n.\n", + "warn_choice": "⚠️ Escribe 0, 1 o 2.\n", + "after_menu": "¿Qué desea hacer a continuación?", + "after_opt1": "Volver al menú principal", + "after_opt6": "Copiar a la tarjeta SD ahora (modo 6)", + "after_opt_files": "Copiar los archivos generados a la tarjeta SD", + "press_enter": "Pulsa Intro para cerrar...", + "press_enter_cont": "Pulsa Intro para continuar de todas formas...", + "path_local": " 1 → Disco local (ej: D:\\Recalbox\\roms)", + "path_network": " 2 → Red / NAS (ej: \\\\192.168.1.1\\share\\roms)", + "path_choice": "Su elección (0, 1 o 2): ", + "path_local_lbl": "Ruta de la carpeta (0 para volver): ", + "path_net_lbl": "Ruta de red (0 para volver): ", + "path_not_found": "❌ Carpeta no encontrada. Verifica la ruta e inténtalo de nuevo.\n", + "sd_erase_ask": "¿Desea borrarla completamente antes de continuar?", + "sd_erased": "🗑️ Carpeta borrada y recreada.", + "sd_erase_err": "❌ Error al borrar. Cierra los archivos abiertos en sd_card/ e inténtalo de nuevo.", + "sd_kept": " ℹ️ Contenido conservado, los archivos existentes serán sobreescritos o ignorados.", + "ext_title": "🚀 PASO 1/3 — EXTRACCIÓN DE IMÁGENES", + "ext_roms_where": "\n📍 ¿DÓNDE ESTÁN SUS ROMS?", + "ext_systems": "sistemas con gamelist.xml detectados", + "ext_games_found": "juegos encontrados", + "ext_xml_err": "ERROR XML", + "ext_already": "ya existe", + "ext_missing_tag": "FALTA", + "ext_summary_copied": "copiadas", + "ext_summary_skip": "ya presentes", + "ext_summary_miss": "faltantes", + "ext_log_header": "=== IMÁGENES FALTANTES ===\n", + "ext_log_source": "Fuente: ", + "ext_log_summary": "=== RESUMEN ===\n", + "ext_log_games": "Juegos analizados : ", + "ext_log_copied": "Imágenes copiadas : ", + "ext_log_skipped": "Ya presentes : ", + "ext_log_missing": "Imágenes faltantes: ", + "conv_title": "🖼️ PASO 2/3 — CONVERSIÓN 128x32", + "conv_png_only": "\n⚠️ INFO: Solo los archivos PNG serán convertidos a 128x32.\n Los GIF se conservan tal cual.\n", + "conv_gif_info": "GIF encontrados → conservados sin modificación.", + "conv_png_count": "PNG a convertir a 128x32", + "conv_summary_done": "PNG convertidos", + "conv_summary_err": "errores", + "conv_summary_gif": "GIF conservados", + "conv_no_pillow": "❌ Pillow no está instalado. Instálalo con: pip install Pillow", + "conv_src_where": "\n📍 ¿DÓNDE ESTÁN LAS IMÁGENES A CONVERTIR?\n (carpeta systems/ con subcarpetas por sistema)", + "cache_title": "💾 PASO 3/3 — BUILD GAMES CACHE", + "cache_scan": "[INFO] Analizando: ", + "cache_found_sys": "sistemas,", + "cache_found_games": "juegos encontrados", + "cache_no_sys": "⚠️ No se encontraron sistemas. Verifica la carpeta systems/.", + "cache_size": "KB", + "cache_sys_where": "\n📍 ¿DÓNDE ESTÁ LA CARPETA SYSTEMS?", + "cache_sys_detected": "✅ Carpeta systems/ detectada: ", + "cache_sys_use": "¿Usar esta carpeta?", + "cache_sys_missing": "⚠️ No se encontró carpeta systems/ en ", + "mode1_title": "MODO 1 — Extracción + Conversión 128x32 + Build Cache", + "mode2_title": "MODO 2 — Solo Extracción Gamelist", + "mode3_title": "MODO 3 — Solo Conversión 128x32", + "mode4_title": "MODO 4 — Solo Build Games Cache", + "done": "🎉 ¡TERMINADO!", + "done_sd": "📂 Carpeta SD card : ", + "done_cache": "💾 Cache : ", + "done_log": "📋 Log imágenes faltantes: ", + "done_copy_sd": "Copia el contenido de esta carpeta en la raíz de tu tarjeta SD.", + "done_extracted": "📂 Imágenes extraídas en: ", + "done_log2": "📋 Log: ", + "done_converted": "📂 Imágenes convertidas en: ", + "done_cache2": "💾 Cache generado: ", + "done_copy_cache": "Copia estos archivos en la raíz de tu tarjeta SD.", + "done_cache_files": "💾 Archivos generados:", + "src_ok": "✅ Carpeta: ", + "roms_ok": "✅ Carpeta ROMs: ", + "sysc_title": "MODO 5 — Generar systems_cache.dat", + "sysc_no_defaults": "⚠️ No se encontró carpeta _defaults/ en systems/.", + "sysc_found": lambda n: f" 📂 {n} sistemas encontrados en _defaults/", + "sysc_line": lambda t, n: f" {'✅' if t != '?' else '⚠️ '} {t} {n}", + "sysc_unknown": "⚠️ No se encontró .gif ni .png para este sistema.", + "sysc_done": lambda n, p: f"✅ {n} sistemas escritos en {p}", + "sysc_copy": "Copia systems_cache.dat en la raíz de tu tarjeta SD.", + "sysc_hint": " (El ESP32 lo usará en el próximo arranque sin rescanear)", + "flash_title": "MODO 6 — Copiar a la tarjeta SD", + "flash_no_sdcard": "⚠️ La carpeta sd_card/ está vacía o no existe. Ejecuta otro modo primero.", + "flash_no_win": "⚠️ Este modo solo está disponible en Windows.", + "flash_admin_warn": "⚠️ Se requieren derechos de administrador. Relanza el script como Administrador.", + "flash_drives_title": "\n💾 UNIDADES DISPONIBLES (extraíbles / tarjeta SD) :", + "flash_no_drives": "⚠️ No se detectó ninguna unidad extraíble. Inserta tu tarjeta SD e inténtalo de nuevo.", + "flash_drive_choice": "Elige la unidad de destino (0 para volver): ", + "flash_drive_warn": "⚠️ Elección inválida.\n", + "flash_drive_sel": lambda d, s: f"\n✅ Destino: {d} ({s})", + "flash_mode_title": "\n⚙️ MODO DE COPIA", + "flash_mode_opt1": "Formatear en FAT32 y copiar (ATENCIÓN: borra todo en la SD)", + "flash_mode_opt2": "Solo copiar — sobreescribir archivos existentes", + "flash_mode_opt3": "Solo copiar — ignorar archivos existentes (conservar lo que ya está)", + "flash_mode_choice": "Su elección (0-2): ", + "flash_mode_warn": "⚠️ Escribe 0, 1 o 2.\n", + "flash_fmt_confirm": lambda d: f"⚠️ TODOS LOS DATOS en {d} serán borrados. ¿Estás seguro?", + "flash_fmt_abort": " ↩ Formateo cancelado.", + "flash_fmt_start": lambda d: f"🗑️ Formateando {d} en FAT32...", + "flash_fmt_ok": "✅ Formateo completado.", + "flash_fmt_err": lambda e: f"❌ Error de formateo: {e}", + "flash_copy_start": lambda s, d: f"\n📋 Copiando {s} → {d} (robocopy /MT:32)...", + "flash_copy_ok": "✅ Copia completada.", + "flash_copy_err": lambda c: f"⚠️ Robocopy terminó con código {c} (revisa la salida anterior).", + "main_opt6": "Copiar sd_card/ a la tarjeta SD (rápido, robocopy)", + "main_opt5": "Generar systems_cache.dat (índice de sistemas ESP32)", + "pause_hint": " ⏸️ Pulsa [ESC] para pausar", + "pause_title": "\n⏸️ PAUSADO", + "pause_opt1": "Continuar", + "pause_opt2": "Saltar al siguiente paso", + "pause_opt3": "Detener el script", + "pause_choice": "Su elección (1-3): ", + "pause_warn": "⚠️ Escribe 1, 2 o 3.\n", + "pause_resuming": "▶️ Reanudando...", + "pause_skipping": "⏭️ Saltando al siguiente paso...", + "pause_stopping": "🛑 Parada solicitada.", + "sys_sel_title": "🎮 SISTEMAS DETECTADOS", + "sys_sel_none": "⚠️ No se encontró ningún sistema con gamelist.xml en esta carpeta.", + "sys_sel_prompt": "¿Qué sistemas procesar?", + "sys_sel_opt_all": "Todos los sistemas", + "sys_sel_opt_pick": "Elegir sistemas específicos", + "sys_sel_pick_hint": "Introduce los números separados por comas (ej: 1,3,5) o 0 para todos:", + "sys_sel_warn": "⚠️ Selección inválida. Inténtalo de nuevo.\n", + "sys_sel_selected": lambda n: f"✅ {n} sistema(s) seleccionado(s).", + }, +} + +# Global translation dict (set in main after language selection) +T = TRANSLATIONS["fr"] + + +def tr(key): + return T[key] + + +# ───────────────────────────────────────────────────────────────────────────── +# INSTALLATION AUTOMATIQUE DES DÉPENDANCES +# ───────────────────────────────────────────────────────────────────────────── + +PIL_AVAILABLE = False + + +def ensure_dependencies(): + global PIL_AVAILABLE + try: + from PIL import Image + + PIL_AVAILABLE = True + return + except ImportError: + print(tr("pillow_installing")) + import subprocess + + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "Pillow"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + print(tr("pillow_ok")) + PIL_AVAILABLE = True + except subprocess.CalledProcessError: + print(tr("pillow_fail")) + PIL_AVAILABLE = False + + +# ───────────────────────────────────────────────────────────────────────────── +# CONSTANTES +# ───────────────────────────────────────────────────────────────────────────── + +TARGET_W = 128 +TARGET_H = 32 +EXTENSIONS_CACHE = {".gif": 0x67, ".png": 0x70} +LETTERS = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ" +NB_LETTERS = len(LETTERS) + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES COMMUNS +# ───────────────────────────────────────────────────────────────────────────── + + +def sep(char="═", width=70): + print(char * width) + + +def title(text): + sep() + print(f" {text}") + sep() + + +def ask_yes_no(question): + yn = tr("yes_no") + yes = tr("yes_vals") + no = tr("no_vals") + while True: + r = input(f"{question} {yn} : ").strip().lower() + if r in yes: + return True + if r in no: + return False + print(tr("warn_yn")) + + +def ask_path(must_exist=True): + """Demande un chemin local ou réseau. Retourne Path ou None (retour arrière).""" + while True: + print() + print(tr("path_local")) + print(tr("path_network")) + print(f" 0 → {tr('back')}") + print() + choix = input(tr("path_choice")).strip() + if choix == "0": + return None + if choix not in ("1", "2"): + print(tr("warn_choice")) + continue + lbl = tr("path_local_lbl") if choix == "1" else tr("path_net_lbl") + chemin = input(lbl).strip().strip('"') + if chemin == "0": + continue + p = Path(chemin) + if not must_exist or (p.exists() and p.is_dir()): + return p + print(tr("path_not_found")) + + +def sanitize_filename(name: str) -> str: + for ch in r'\/:*?"<>|': + name = name.replace(ch, "_") + name = name.replace(" ", "") + return name.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# SD CARD +# ───────────────────────────────────────────────────────────────────────────── + + +def get_sd_card_dir(script_dir: Path) -> Path: + """ + Emplacement GUI/Tk : dossier temporaire dans %LOCALAPPDATA%/Temp. + On utilise un sous-dossier pour éviter de polluer le temp global. + """ + local_appdata = os.environ.get("LOCALAPPDATA") + if local_appdata: + base = Path(local_appdata) / "Temp" + else: + # fallback (devrait être rare sur Windows) + base = Path(tempfile.gettempdir()) # type: ignore[name-defined] + + return base / "RetroBoxLED" + + +def prepare_sd_card(sd_dir: Path, interactive: bool = True): + if sd_dir.exists(): + items = list(sd_dir.iterdir()) + if items: + print(f"\n⚠️ '{sd_dir}' ({len(items)} items)") + if not interactive: + # GUI: pas de question clavier. On garde le contenu. + print(tr("sd_kept")) + return + if ask_yes_no(tr("sd_erase_ask")): + try: + shutil.rmtree(sd_dir) + sd_dir.mkdir(parents=True) + print(tr("sd_erased")) + except Exception as e: + print(f"{tr('sd_erase_err')}\n {e}") + input(tr("press_enter_cont")) + sd_dir.mkdir(parents=True, exist_ok=True) + else: + print(tr("sd_kept")) + else: + sd_dir.mkdir(parents=True, exist_ok=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# EXTRACTION GAMELIST +# ───────────────────────────────────────────────────────────────────────────── + +_INVALID_XML_CHARS = re.compile( + r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]" r"|&#(?:x[0-9a-fA-F]+|\d+);" +) + + +def _is_valid_codepoint(m: re.Match) -> bool: + s = m.group() + if not s.startswith("&#"): + return False + inner = s[2:-1] + code = int(inner[1:], 16) if inner.startswith("x") else int(inner) + return ( + code == 0x9 + or code == 0xA + or code == 0xD + or 0x20 <= code <= 0xD7FF + or 0xE000 <= code <= 0xFFFD + or 0x10000 <= code <= 0x10FFFF + ) + + +def sanitize_xml(raw: bytes) -> bytes: + text = raw.decode("utf-8", errors="replace") + cleaned = _INVALID_XML_CHARS.sub( + lambda m: "" if not _is_valid_codepoint(m) else m.group(), text + ) + return cleaned.encode("utf-8") + + +def resolve_image_path(sys_dir: Path, raw_path: str) -> Path: + p = raw_path.strip() + if p.startswith("/"): + return Path(p) + return sys_dir / p + + +def parse_gamelist(gamelist_path: Path): + raw = gamelist_path.read_bytes() + cleaned = sanitize_xml(raw) + root = ET.fromstring(cleaned) + return root.findall(".//game") + + +def ask_extraction_config(): + """ + Depuis la MAJ Recalbox, la balise remplace / + Les images sont copiées directement dans systems// (pas de sous-dossier). + Retourne [(tag, folder)] fixe. + """ + return [("logo", "")] + + +def extract_system( + sys_dir, + systems_out, + tag_configs, + sys_index, + total_systems, + log_file, + progress_cb=None, + progress_global_total_images: int = 0, + progress_global_done_offset: int = 0, +): + sys_name = sys_dir.name + print(f"\n[{sys_index}/{total_systems}] 📁 {sys_name}") + + try: + games = parse_gamelist(sys_dir / "gamelist.xml") + except ET.ParseError as e: + msg = f"[{sys_name}] {tr('ext_xml_err')} : {e}" + print(f" ❌ {msg}") + log_file.write(msg + "\n") + return 0, 0, 0, 0 + + total = len(games) + copied = 0 + skipped = 0 + missing = 0 + print(f" 🎮 {total} {tr('ext_games_found')}") + total_images_global = max(progress_global_total_images, 1) + + for i, game in enumerate(games, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + path_elem = game.find("path") + if path_elem is None: + missing += 1 + continue + + raw_path = unquote(path_elem.text or "").strip() + if not raw_path: + missing += 1 + continue + + game_name = sanitize_filename(Path(raw_path).stem) + + for tag, folder in tag_configs: + img_elem = game.find(tag) + if img_elem is None or not (img_elem.text or "").strip(): + missing += 1 + continue + + image_raw = unquote(img_elem.text.strip()) + src_image = resolve_image_path(sys_dir, image_raw) + ext = src_image.suffix or ".png" + + dst_dir = systems_out / sys_name / folder + dst_dir.mkdir(parents=True, exist_ok=True) + dst_image = dst_dir / f"{game_name}{ext}" + + current_file = f"{game_name}{ext}" + if progress_cb is not None: + # Détail “images copiées/total + fichier en cours”. + # On envoie la progression globale (toutes les images, tous systèmes) + # pour que le % soit cohérent. + progress_cb( + "extraction_imgs", + progress_global_done_offset + copied + skipped, + progress_global_total_images, + f"{copied}/{total} — {current_file}", + ) + + if dst_image.exists(): + skipped += 1 + print( + f" {i:4d}/{total} ⏭️ [{folder}] {game_name}{ext} ({tr('ext_already')})" + ) + continue + + if not src_image.exists(): + missing += 1 + print( + f" {i:4d}/{total} ⚠️ {tr('ext_missing_tag')} ({tag}): {src_image.name}" + ) + log_file.write(f"[{sys_name}] {game_name} ({tag}) → {src_image}\n") + continue + + shutil.copy2(src_image, dst_image) + copied += 1 + + if progress_cb is not None: + # Progression globale après copie. + progress_cb( + "extraction_imgs", + progress_global_done_offset + copied + skipped, + progress_global_total_images, + f"{copied}/{total} — {current_file}", + ) + + print(f" {i:4d}/{total} ✅ [{folder}] {game_name}{ext}") + time.sleep(0.003) + + print( + f" → ✅ {copied} {tr('ext_summary_copied')} | " + f"⏭️ {skipped} {tr('ext_summary_skip')} | " + f"⚠️ {missing} {tr('ext_summary_miss')}" + ) + return total, copied, skipped, missing + + +def ask_system_selection(roms_root: Path): + """ + Liste les systèmes détectés dans roms_root et propose à l'utilisateur + de choisir lesquels traiter. Retourne la liste des Path sélectionnés, + ou None si l'utilisateur revient en arrière. + """ + systems = sorted( + [ + d + for d in roms_root.iterdir() + if d.is_dir() and (d / "gamelist.xml").exists() + ], + key=lambda d: d.name.lower(), + ) + + sep("─") + print(f"\n{tr('sys_sel_title')}") + sep("─") + + if not systems: + print(tr("sys_sel_none")) + return None + + for i, s in enumerate(systems, 1): + print(f" {i:3d} → {s.name}") + print() + print(f" {tr('sys_sel_prompt')}") + print() + print(f" 1 → {tr('sys_sel_opt_all')}") + print(f" 2 → {tr('sys_sel_opt_pick')}") + print(f" 0 → {tr('back')}") + print() + + while True: + raw = input(" > ").strip() + if raw == "0": + return None + if raw == "1": + print(tr("sys_sel_selected")(len(systems))) + return systems + if raw == "2": + break + print(tr("sys_sel_warn")) + + # Sélection manuelle + print() + print(f" {tr('sys_sel_pick_hint')}") + print() + while True: + raw = input(" > ").strip() + if raw == "0": + print(tr("sys_sel_selected")(len(systems))) + return systems + parts = [p.strip() for p in raw.split(",") if p.strip()] + selected = [] + valid = True + seen = set() + for p in parts: + if not p.isdigit(): + valid = False + break + idx = int(p) + if idx < 1 or idx > len(systems) or idx in seen: + valid = False + break + seen.add(idx) + selected.append(systems[idx - 1]) + if valid and selected: + print(tr("sys_sel_selected")(len(selected))) + return selected + print(tr("sys_sel_warn")) + + +def run_extraction( + roms_root, + systems_out, + tag_configs, + log_file, + selected_systems=None, + progress_cb=None, + listen_keyboard: bool = True, +): + if selected_systems is not None: + systems = selected_systems + else: + systems = [ + d + for d in roms_root.iterdir() + if d.is_dir() and (d / "gamelist.xml").exists() + ] + total_systems = len(systems) + print(f"\n📂 {total_systems} {tr('ext_systems')}") + print(tr("pause_hint")) + + # Pour que le % reflète la progression globale (images), + # on calcule le total global de "games" sur tous les systèmes. + global_total_images = 0 + for sys_dir in systems: + try: + games = parse_gamelist(sys_dir / "gamelist.xml") + global_total_images += len(games) + except Exception: + # Si un système a un XML invalide, il ne contribuera pas au total. + pass + global_total_images = max(global_total_images, 1) + + # compteur global d’images "faites" = copiées + déjà présentes (skipped) + global_done_images = 0 + + PAUSE.start(listen_keyboard=listen_keyboard) + grand = {"games": 0, "copied": 0, "skipped": 0, "missing": 0, "done": 0} + for idx, sys_dir in enumerate(systems, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + if progress_cb is not None: + sys_label = f"{idx}/{total_systems} — {sys_dir.name}" + progress_cb( + "extraction", + global_done_images, + global_total_images, + sys_label, + ) + + g, c, s, m = extract_system( + sys_dir, + systems_out, + tag_configs, + idx, + total_systems, + log_file, + progress_cb=progress_cb, + progress_global_total_images=global_total_images, + progress_global_done_offset=global_done_images, + ) + + global_done_images += c + s + + grand["games"] += g + grand["copied"] += c + grand["skipped"] += s + grand["missing"] += m + if g > 0: + grand["done"] += 1 + + PAUSE.stop() + + return grand, total_systems + + +# ───────────────────────────────────────────────────────────────────────────── +# CONVERSION 128x32 +# ───────────────────────────────────────────────────────────────────────────── + + +def convert_image_file(src: Path, dst: Path): + from PIL import Image + + with Image.open(src) as img: + img = img.convert("RGBA") + orig_w, orig_h = img.size + ratio = min(TARGET_W / orig_w, TARGET_H / orig_h) + new_w = int(orig_w * ratio) + new_h = int(orig_h * ratio) + resized = img.resize((new_w, new_h), Image.LANCZOS) + canvas = Image.new("RGBA", (TARGET_W, TARGET_H), (0, 0, 0, 255)) + offset_x = (TARGET_W - new_w) // 2 + offset_y = (TARGET_H - new_h) // 2 + canvas.paste(resized, (offset_x, offset_y), resized) + canvas.convert("RGB").save(dst, "PNG", optimize=False, interlace=False) + + +def open_folder_in_explorer(folder: Path) -> None: + try: + folder = folder.resolve() + except Exception: + folder = folder + + try: + if os.name == "nt": + os.startfile(str(folder)) # type: ignore[attr-defined] + return + except Exception: + pass + + # fallback (non-Windows) + try: + import subprocess + + subprocess.Popen(["xdg-open", str(folder)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # type: ignore[call-arg] + except Exception: + pass + + +def popup_confirm_before_conversion(out_dir: Path) -> bool: + """ + Popup Tkinter (bloquant) : + - Continuer -> True + - Explorer -> ouvre out_dir + - X -> False + Si Tkinter échoue, on retourne True (fallback). + """ + try: + import tkinter as tk + except Exception: + return True + + ok_holder = {"ok": False} + + root = tk.Tk() + root.title("RetroBoxLED — Conversion 128x32") + root.resizable(False, False) + + # Message + msg = tk.Label( + root, + text=f"Préparation conversion 128x32.\n\nSortie :\n{str(out_dir)}", + justify="left", + padx=14, + pady=12, + ) + msg.pack() + + buttons = tk.Frame(root, padx=10, pady=10) + buttons.pack() + + def on_explore(): + open_folder_in_explorer(out_dir) + + def on_continue(): + ok_holder["ok"] = True + root.destroy() + + def on_close(): + root.destroy() + + explore_btn = tk.Button( + buttons, text="Explorer dossier", width=18, command=on_explore + ) + explore_btn.grid(row=0, column=0, padx=8, pady=6) + + cont_btn = tk.Button(buttons, text="Continuer", width=18, command=on_continue) + cont_btn.grid(row=0, column=1, padx=8, pady=6) + + root.protocol("WM_DELETE_WINDOW", on_close) + root.mainloop() + return bool(ok_holder["ok"]) + + +def run_conversion( + systems_dir: Path, + progress_cb=None, + listen_keyboard: bool = True, +): + if not PIL_AVAILABLE: + print(tr("conv_no_pillow")) + return + + print(tr("conv_png_only")) + + png_files = list(systems_dir.rglob("*.png")) + gif_files = list(systems_dir.rglob("*.gif")) + total = len(png_files) + done = errors = 0 + + # Assure que l'UI passe en "Conversion" même si total == 0 + if progress_cb is not None: + progress_cb( + "conversion", + 0, + total, + "aucun PNG à convertir" if total == 0 else "conversion en cours", + ) + + if gif_files: + print(f" 🎞️ {len(gif_files)} {tr('conv_gif_info')}") + print(f" 🖼️ {total} {tr('conv_png_count')}") + print(tr("pause_hint")) + sep("─") + + PAUSE.start(listen_keyboard=listen_keyboard) + for i, src in enumerate(png_files, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + if progress_cb is not None: + # Envoi : system|filename (pour que la GUI affiche system en ligne 1, + # et le filename tronqué en ligne 2 sans pousser la mise en page) + progress_cb("conversion", i, total, f"{src.parent.name}|{src.name}") + + try: + if "_defaults" in str(src.parent).lower(): + continue + convert_image_file(src, src) + done += 1 + print(f" {i:5d}/{total} ✅ {src.relative_to(systems_dir)}") + except Exception as e: + errors += 1 + print(f" {i:5d}/{total} ❌ {src.relative_to(systems_dir)} — {e}") + PAUSE.stop() + + sep("─") + print( + f"✅ {done} {tr('conv_summary_done')} | " + f"❌ {errors} {tr('conv_summary_err')} | " + f"🎞️ {len(gif_files)} {tr('conv_summary_gif')}" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# BUILD GAMES CACHE — index bigramme 702 entrees +# +# Structure de l'index par systeme : 702 x 4 bytes (offsets absolus) +# Index 0 = '#' (chiffres, tirets, etc.) +# Index 1 = 'A' (jeux commencant par A + caractere non-lettre) +# Index 2..27 = 'AA'..'AZ' +# Index 28 = 'B' +# Index 29..54 = 'BA'..'BZ' +# ... +# Index 703 = 'Z' +# Total = 1 + 26 * 27 = 703 (indices 0..702) +# ───────────────────────────────────────────────────────────────────────────── + +NB_IDX = 703 # nombre total d'entrees dans la table bigramme + + +def bigram_index(name): + """ + Calcule l'index bigramme (0..702) pour un nom de jeu. + - Commence par non-lettre -> 0 (#) + - Commence par A seul (ex: "a1") -> 1 + - Commence par AA..AZ -> 2..27 + - Commence par B seul -> 28 + - etc. + """ + if not name: + return 0 + c1 = name[0].upper() + if not c1.isalpha(): + return 0 # '#' + i1 = ord(c1) - ord("A") # 0..25 + base = 1 + i1 * 27 # base pour la lettre c1 + + if len(name) < 2: + return base # lettre seule + + c2 = name[1].upper() + if not c2.isalpha(): + return base # lettre seule (2eme char non-lettre) + + i2 = ord(c2) - ord("A") # 0..25 + return base + i2 + 1 # base + 1..26 + + +def collect_games_for_folder(systems_dir: Path, folder: str): + """ + Collecte les jeux pour un dossier image specifique. + folder = "" pour la racine du systeme, + folder = "" pour la racine du système (pas de sous-dossier). + Retourne un dict { sysname: defaultdict(list) } + """ + result = {} + for sysname in sorted(os.listdir(systems_dir)): + syspath = systems_dir / sysname + if not syspath.is_dir() or sysname.lower() == "_defaults": + continue + + if folder: + scan_dir = syspath / folder + if not scan_dir.exists() or not scan_dir.is_dir(): + continue + scan_dirs = [scan_dir] + else: + scan_dirs = [syspath] + + games = {} + try: + for subdir in scan_dirs: + for fname in os.listdir(subdir): + if (subdir / fname).is_dir(): + continue + name, ext = os.path.splitext(fname) + ext = ext.lower() + if ext not in EXTENSIONS_CACHE: + continue + ftype = EXTENSIONS_CACHE[ext] + key = name.lower() + if key not in games or ftype == 0x67: + games[key] = (name, ftype) + except PermissionError: + continue + + if not games: + continue + + by_idx = defaultdict(list) + for key in sorted(games.keys()): + _orig, ftype = games[key] + by_idx[bigram_index(key)].append((key, ftype)) + + result[sysname] = by_idx + + return result + + +def _write_cache_binary(data: dict, output_path: Path): + """Ecrit un games_cache.bin avec index bigramme 702 entrees.""" + total_systems = len(data) + total_games = sum(len(gl) for by_idx in data.values() for gl in by_idx.values()) + + if total_systems == 0: + print(tr("cache_no_sys")) + return 0, 0 + + HEADER_SIZE = 4 + total_systems * 36 + data_buf = bytearray() + sys_offsets = {} + + for sysname, by_idx in data.items(): + sys_offsets[sysname] = len(data_buf) + letter_table_pos = len(data_buf) + data_buf += b"\x00" * (NB_IDX * 4) + + idx_offsets = [0] * NB_IDX + for li in range(NB_IDX): + games = by_idx.get(li, []) + if not games: + continue + idx_offsets[li] = HEADER_SIZE + len(data_buf) + for gamename, gtype in games: + name_bytes = gamename.lower().encode("utf-8") + b"\x00" + data_buf += bytes([gtype]) + name_bytes + + for li in range(NB_IDX): + pos = letter_table_pos + li * 4 + data_buf[pos : pos + 4] = struct.pack(" 0: + generated.append((fname, nb_sys, nb_games)) + + print(f"\n[INFO] {len(generated)} cache(s) genere(s) :") + for fname, nb_sys, nb_games in generated: + print(f" {fname} ({nb_sys} systemes, {nb_games} jeux)") + return [ + (output_dir / fname, nb_sys, nb_games) for fname, nb_sys, nb_games in generated + ] + + +def _ask_roms_and_config(): + """Boucle commune : demande roms + sélection systèmes. + Depuis la MAJ Recalbox, la config image est fixe : balise -> racine du système. + Retourne (roms_root, tag_configs, selected_systems) ou (None, None, None).""" + while True: + print(tr("ext_roms_where")) + sep("─") + roms_root = ask_path() + if roms_root is None: + return None, None, None + print(f"{tr('roms_ok')}{roms_root}") + + selected_systems = ask_system_selection(roms_root) + if selected_systems is None: + print(tr("back_roms")) + continue + + tag_configs = ask_extraction_config() # retourne toujours [("logo", "")] + return roms_root, tag_configs, selected_systems + + +def _write_log(log_file, roms_root, grand): + log_file.write(tr("ext_log_header")) + log_file.write(f"{tr('ext_log_source')}{roms_root}\n\n") + log_file.write(tr("ext_log_summary")) + log_file.write(f"{tr('ext_log_games')}{grand['games']}\n") + log_file.write(f"{tr('ext_log_copied')}{grand['copied']}\n") + log_file.write(f"{tr('ext_log_skipped')}{grand['skipped']}\n") + log_file.write(f"{tr('ext_log_missing')}{grand['missing']}\n") + + +def mode_full(sd_dir: Path): + title(tr("mode1_title")) + prepare_sd_card(sd_dir) + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + roms_root, tag_configs, selected_systems = _ask_roms_and_config() + if roms_root is None: + print(tr("back_main")) + return + + sep("─") + print(f"\n{tr('ext_title')}") + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = run_extraction( + roms_root, systems_out, tag_configs, log_file, selected_systems + ) + _write_log(log_file, roms_root, grand) + + sep("─") + if PAUSE.should_stop(): + sep() + print(tr("done")) + print(tr("pause_stopping")) + return + print(f"\n{tr('conv_title')}") + PAUSE.state = PAUSE.RUNNING + if not popup_confirm_before_conversion(systems_out): + return + run_conversion(systems_out) + + sep("─") + if PAUSE.should_stop(): + sep() + print(tr("done")) + print(tr("pause_stopping")) + return + print(f"\n{tr('cache_title')}") + build_cache(systems_out, sd_dir) + + # ── Téléchargement _defaults depuis GitHub ─────────────────────────────── + sep("─") + download_defaults(sd_dir) + + # ── Auto-génération systems_cache.dat ──────────────────────────────────── + sep("─") + print(f"\n{tr('sysc_title')}") + sysc_out = sd_dir / "systems_cache.dat" + build_systems_cache(systems_out, sysc_out) + + sep() + print(tr("done")) + print(f"{tr('done_sd')}{sd_dir}") + print(f"{tr('done_log')}{log_path}") + print(f"\n {tr('done_copy_sd')}") + + +def mode_extract_only(sd_dir: Path): + title(tr("mode2_title")) + prepare_sd_card(sd_dir) + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + roms_root, tag_configs, selected_systems = _ask_roms_and_config() + if roms_root is None: + print(tr("back_main")) + return + + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = run_extraction( + roms_root, systems_out, tag_configs, log_file, selected_systems + ) + _write_log(log_file, roms_root, grand) + + sep() + print(tr("done")) + print(f"{tr('done_extracted')}{systems_out}") + print(f"{tr('done_log2')}{log_path}") + + +def mode_convert_only(sd_dir: Path): + title(tr("mode3_title")) + + if not PIL_AVAILABLE: + print(tr("conv_no_pillow")) + return + + print(tr("conv_src_where")) + sep("─") + src_dir = ask_path() + if src_dir is None: + print(tr("back_main")) + return + print(f"{tr('src_ok')}{src_dir}") + + run_conversion(src_dir) + + sep() + print(tr("done")) + print(f"{tr('done_converted')}{src_dir}") + + +def mode_cache_only(sd_dir: Path): + title(tr("mode4_title")) + + default_systems = sd_dir / "systems" + if default_systems.exists(): + print(f"{tr('cache_sys_detected')}{default_systems}") + if ask_yes_no(tr("cache_sys_use")): + systems_dir = default_systems + else: + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + else: + print(f"{tr('cache_sys_missing')}{sd_dir}") + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + + sd_dir.mkdir(parents=True, exist_ok=True) + generated = build_cache(systems_dir, sd_dir) + + sep() + print(tr("done")) + if generated: + print(tr("done_cache_files")) + for path, nb_sys, nb_games in generated: + print(f" 💾 {path} ({nb_sys} syst., {nb_games} jeux)") + print(f"\n {tr('done_copy_cache')}") + return [path for path, _, _ in generated] if generated else [] + + +# ───────────────────────────────────────────────────────────────────────────── +# TÉLÉCHARGEMENT _defaults DEPUIS GITHUB +# ───────────────────────────────────────────────────────────────────────────── + +GITHUB_API_URL = ( + "https://api.github.com/repos/Jamyz/RetroBoxLED/contents/systems/_defaults" +) +GITHUB_RAW_BASE = ( + "https://raw.githubusercontent.com/Jamyz/RetroBoxLED/main/systems/_defaults" +) + + +def download_defaults( + sd_dir: Path, + progress_cb=None, + listen_keyboard: bool = True, + replace_existing=None, + download_missing=None, +): + """ + Propose de télécharger _defaults/ depuis GitHub. + - Si absent → propose de télécharger + - Si présent → propose de mettre à jour (remplace) + - Dans les deux cas, l'utilisateur peut refuser + """ + import urllib.request + import json + + defaults_dir = sd_dir / "systems" / "_defaults" + exists = defaults_dir.exists() and any(defaults_dir.iterdir()) + + sep("─") + print(f"\n{tr('dl_title')}") + sep("─") + print(f" ↪ defaults_dir = {defaults_dir}") + print(f" ↪ GITHUB_API_URL = {GITHUB_API_URL}") + print(f" ↪ GITHUB_RAW_BASE = {GITHUB_RAW_BASE}") + + # Si lancé depuis la GUI (listen_keyboard=False), on évite ask_yes_no() + # et on s'appuie sur replace_existing/download_missing fournis. + if exists: + print(tr("dl_exists")) + if replace_existing is None: + if listen_keyboard: + replace_existing = ask_yes_no(tr("dl_ask_update")) + else: + replace_existing = False # safe default: ne pas effacer + if not replace_existing: + print(tr("dl_skip")) + return + print(tr("dl_replacing")) + shutil.rmtree(defaults_dir) + defaults_dir.mkdir(parents=True, exist_ok=True) + else: + print(tr("dl_missing")) + if download_missing is None: + if listen_keyboard: + download_missing = ask_yes_no(tr("dl_ask_download")) + else: + download_missing = False # safe default: ne pas télécharger + if not download_missing: + print(tr("dl_skip")) + return + + # (si on arrive ici: soit on remplace, soit il faut télécharger) + defaults_dir.mkdir(parents=True, exist_ok=True) + + # Récupère la liste des fichiers via l'API GitHub + try: + req = urllib.request.Request( + GITHUB_API_URL, headers={"User-Agent": "recalbox-toolkit"} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + files = json.loads(resp.read().decode("utf-8")) + except Exception as e: + print(tr("dl_fail_api")) + print(f" {e}") + return + + # Filtre uniquement les fichiers png/gif + media_files = [ + f + for f in files + if f.get("type") == "file" + and Path(f["name"]).suffix.lower() in (".png", ".gif") + ] + + total = len(media_files) + print(tr("dl_starting")) + done = 0 + + PAUSE.start(listen_keyboard=listen_keyboard) + try: + for i, f in enumerate(media_files, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + fname = f["name"] + if progress_cb is not None: + progress_cb("download_defaults", i, total, fname) + + raw_url = f"{GITHUB_RAW_BASE}/{urllib.request.quote(fname)}" + dst = defaults_dir / fname + try: + urllib.request.urlretrieve(raw_url, dst) + done += 1 + print(tr("dl_file_ok")(fname, i, total)) + except Exception as e: + print(tr("dl_file_err")(fname, e)) + finally: + PAUSE.stop() + + print(tr("dl_done")(done)) + + +# ───────────────────────────────────────────────────────────────────────────── +# BUILD SYSTEMS CACHE (systems_cache.dat pour l'ESP32) +# ───────────────────────────────────────────────────────────────────────────── + + +def build_systems_cache( + systems_dir: Path, + output_path: Path, + progress_cb=None, +): + """ + Génère systems_cache.dat au format attendu par l'ESP32 : + g nes + p snes + g neogeo + Scanne systems_dir/_defaults/ — un fichier par système (gif prioritaire). + """ + defaults_dir = systems_dir / "_defaults" + if not defaults_dir.exists(): + print(tr("sysc_no_defaults")) + return 0 + + # Collecte tous les noms de systèmes présents dans _defaults/ + entries = {} + for f in defaults_dir.iterdir(): + if f.is_file() and f.suffix.lower() in (".gif", ".png"): + stem = f.stem.lower() + ftype = "g" if f.suffix.lower() == ".gif" else "p" + # gif prioritaire sur png + if stem not in entries or ftype == "g": + entries[stem] = (f.stem, ftype) + + count = len(entries) + print(tr("sysc_found")(count)) + + stems = sorted(entries.keys()) + total = len(stems) + with open(output_path, "w", encoding="utf-8", newline="\n") as out: + for idx, stem in enumerate(stems, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + name, ftype = entries[stem] + + # LENT : plus de 800 PNG OU GIF (mais pas les deux) + # -> XOR : (png_over ^ gif_over) + system_dir = systems_dir / name + + def count_ext_over(base: Path, ext_lower: str, limit: int) -> bool: + # Retourne True dès qu'on dépasse "limit" + count = 0 + for _root, _dirs, files in os.walk(base): + for fn in files: + if fn.lower().endswith(ext_lower): + count += 1 + if count > limit: + return True + return False + + png_over = False + gif_over = False + if system_dir.exists() and system_dir.is_dir(): + png_over = count_ext_over(system_dir, ".png", 800) + gif_over = count_ext_over(system_dir, ".gif", 800) + + slow_flag = "L" if (png_over or gif_over) else "N" + out.write(f"{ftype} {name} {slow_flag}\n") + print(tr("sysc_line")(ftype, name)) + + if progress_cb is not None: + progress_cb("systems_cache", idx, total, stem) + + return count + + +def mode_systems_cache(sd_dir: Path): + """Mode 5 : Génère systems_cache.dat depuis sd_card/systems/_defaults/""" + title(tr("sysc_title")) + + default_systems = sd_dir / "systems" + if default_systems.exists(): + print(f"{tr('cache_sys_detected')}{default_systems}") + if ask_yes_no(tr("cache_sys_use")): + systems_dir = default_systems + else: + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + else: + print(f"{tr('cache_sys_missing')}{sd_dir}") + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + + sd_dir.mkdir(parents=True, exist_ok=True) + output_path = sd_dir / "systems_cache.dat" + + count = build_systems_cache(systems_dir, output_path) + + sep() + print(tr("done")) + if count > 0: + print(tr("sysc_done")(count, output_path)) + print(f" 💾 {output_path}") + print(f"\n {tr('sysc_copy')}") + print(tr("sysc_hint")) + return [output_path] + return [] + + +# ───────────────────────────────────────────────────────────────────────────── +# MODE 6 — COPIE RAPIDE SUR CARTE SD (Windows / robocopy) +# ───────────────────────────────────────────────────────────────────────────── + + +def _list_removable_drives(): + """ + Liste les lecteurs amovibles sur Windows via WMI (wmic). + Retourne une liste de tuples (lettre, label, taille_lisible). + """ + import subprocess + + drives = [] + try: + out = subprocess.check_output( + [ + "wmic", + "logicaldisk", + "where", + "drivetype=2", + "get", + "DeviceID,VolumeName,Size", + "/format:csv", + ], + text=True, + stderr=subprocess.DEVNULL, + ) + for line in out.splitlines(): + line = line.strip() + if not line or line.startswith("Node"): + continue + parts = line.split(",") + if len(parts) < 4: + continue + _, device, size_str, label = parts[0], parts[1], parts[2], parts[3] + letter = device.strip() + label = label.strip() or "NO LABEL" + try: + size_gb = int(size_str.strip()) / (1024**3) + size_s = f"{size_gb:.1f} GB" + except Exception: + size_s = "? GB" + if letter: + drives.append((letter, label, size_s)) + except Exception: + pass + return drives + + +def _robocopy(src: Path, dst: str, overwrite: bool): + """ + Lance robocopy avec /MT:32 pour une copie rapide. + overwrite=True → /IS /IT (écrase les fichiers identiques aussi) + overwrite=False → /XC /XN /XO (ignore fichiers plus récents/identiques/anciens) + """ + import subprocess + + src_str = str(src) + flags = ["/E", "/MT:32", "/NFL", "/NJH", "/NP"] + if overwrite: + flags += ["/IS", "/IT"] + else: + flags += ["/XC", "/XN", "/XO"] + + cmd = ["robocopy", src_str, dst] + flags + print(tr("flash_copy_start")(src_str, dst)) + + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + i = 0 + while proc.poll() is None: + print( + f"\r {spinner[i % len(spinner)]} Copie en cours...", end="", flush=True + ) + i += 1 + time.sleep(0.1) + print("\r" + " " * 30 + "\r", end="") # efface la ligne spinner + + if proc.returncode < 4: + print(tr("flash_copy_ok")) + else: + print(tr("flash_copy_err")(proc.returncode)) + + +def _flash_files(files: list, sd_dir: Path): + """Copie une liste de fichiers précis sur une carte SD (Windows uniquement).""" + import subprocess + + if sys.platform != "win32": + print(tr("flash_no_win")) + return + + if not files: + print(tr("flash_no_sdcard")) + return + + while True: + print(tr("flash_drives_title")) + drives = _list_removable_drives() + + if not drives: + print(tr("flash_no_drives")) + input(tr("press_enter")) + return + + for i, (letter, label, size) in enumerate(drives, 1): + print(f" {i} → {letter}\\ [{label}] {size}") + print(f" 0 → {tr('back')}") + print() + + raw = input(tr("flash_drive_choice")).strip() + if raw == "0": + print(tr("back_main")) + return + if not raw.isdigit() or not (1 <= int(raw) <= len(drives)): + print(tr("flash_drive_warn")) + continue + + letter, label, size = drives[int(raw) - 1] + dst_drive = Path(f"{letter}\\") + print(tr("flash_drive_sel")(f"{letter}\\ [{label}]", size)) + print() + + for src in files: + dst = dst_drive / src.name + print(f"📋 {src.name} → {dst}") + try: + import shutil as _shutil + + _shutil.copy2(src, dst) + print(f" ✅ OK") + except Exception as e: + print(f" ❌ {e}") + print() + print(tr("flash_copy_ok")) + return + + +def mode_flash_sd(sd_dir: Path): + """Mode 6 : Copie rapide du contenu sd_card/ sur une carte SD via robocopy.""" + title(tr("flash_title")) + + # ── Vérification Windows ────────────────────────────────────────────────── + if sys.platform != "win32": + print(tr("flash_no_win")) + return + + # ── Vérification sd_card/ non vide ─────────────────────────────────────── + if not sd_dir.exists() or not any(sd_dir.iterdir()): + print(tr("flash_no_sdcard")) + return + + while True: + # ── Liste des lecteurs amovibles ────────────────────────────────────── + print(tr("flash_drives_title")) + drives = _list_removable_drives() + + if not drives: + print(tr("flash_no_drives")) + input(tr("press_enter")) + return + + for i, (letter, label, size) in enumerate(drives, 1): + print(f" {i} → {letter}\\ [{label}] {size}") + print(f" 0 → {tr('back')}") + print() + + raw = input(tr("flash_drive_choice")).strip() + if raw == "0": + print(tr("back_main")) + return + if not raw.isdigit() or not (1 <= int(raw) <= len(drives)): + print(tr("flash_drive_warn")) + continue + + letter, label, size = drives[int(raw) - 1] + dst_drive = f"{letter}\\" + print(tr("flash_drive_sel")(f"{letter}\\ [{label}]", size)) + + # ── Choix du mode de copie ──────────────────────────────────────────── + print(tr("flash_mode_title")) + print(f" 1 → {tr('flash_mode_opt2')}") + print(f" 2 → {tr('flash_mode_opt3')}") + print(f" 0 → {tr('back')}") + print() + + while True: + raw2 = input(tr("flash_mode_choice")).strip() + if raw2 == "0": + break + if raw2 in ("1", "2"): + break + print(tr("flash_mode_warn")) + + if raw2 == "0": + continue # retour au choix du lecteur + + # ── Copie robocopy ──────────────────────────────────────────────────── + overwrite = raw2 == "1" + _robocopy(sd_dir, dst_drive, overwrite) + + sep() + print(tr("done")) + return + + +# ───────────────────────────────────────────────────────────────────────────── +# SÉLECTION DE LANGUE +# ───────────────────────────────────────────────────────────────────────────── + + +def select_language(): + global T + print() + print("╔══════════════════════════════════════════════════════════════════╗") + print("║ RetroBoxLED Toolkit for Recalbox ║") + print("╚══════════════════════════════════════════════════════════════════╝") + print() + print(" Choisissez votre langue / Choose your language / Elija su idioma") + print() + print(" 1 → English") + print(" 2 → Français") + print(" 3 → Español") + print() + while True: + raw = input(" > ").strip() + if raw == "1": + T = TRANSLATIONS["en"] + return + if raw == "2": + T = TRANSLATIONS["fr"] + return + if raw == "3": + T = TRANSLATIONS["es"] + return + print(" ⚠️ 1 / 2 / 3\n") + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + + +def main(): + select_language() + ensure_dependencies() + + script_dir = Path(__file__).parent + sd_dir = get_sd_card_dir(script_dir) + + print() + sep() + print(f" {tr('main_title')}") + sep() + print() + print(f" {tr('main_prompt')}") + print() + print(f" 1 → {tr('main_opt1')}") + print(f" 2 → {tr('main_opt2')}") + print(f" 3 → {tr('main_opt3')}") + print(f" 4 → {tr('main_opt4')}") + print(f" 5 → {tr('main_opt5')}") + print(f" 6 → {tr('main_opt6')}") + print(f" 7 → {tr('main_opt7')}") + print(f" 0 → {tr('main_opt_quit')}") + print() + + while True: + choix = input(tr("main_choice")).strip() + if choix in ("0", "1", "2", "3", "4", "5", "6", "7"): + break + print(tr("main_warn")) + + print() + + while True: + if choix == "0": + break + generated_files = [] # fichiers ciblés pour modes 4 et 5 + if choix == "1": + mode_full(sd_dir) + elif choix == "2": + mode_extract_only(sd_dir) + elif choix == "3": + mode_convert_only(sd_dir) + elif choix == "4": + generated_files = mode_cache_only(sd_dir) or [] + elif choix == "5": + generated_files = mode_systems_cache(sd_dir) or [] + elif choix == "6": + mode_flash_sd(sd_dir) + elif choix == "7": + import RetroBoxLED_gui as gui + + gui.run_gui(sys.modules[__name__], sd_dir) + return + + # ── Menu de fin ─────────────────────────────────────────────────────── + print() + sep("─") + print(f" {tr('after_menu')}") + print() + print(f" 1 → {tr('after_opt1')}") + if choix in ("4", "5"): + if generated_files: + print(f" 2 → {tr('after_opt_files')}") + else: + print(f" 2 → {tr('after_opt6')}") + print(f" 0 → {tr('press_enter').replace('...', '')}") + print() + + valid = ["0", "1"] + if choix not in ("4", "5") or generated_files: + valid.append("2") + + while True: + raw = input(" > ").strip() + if raw in valid: + break + print(tr("main_warn")) + + if raw == "0": + break + elif raw == "1": + # Retour au menu principal + print() + sep() + print(f" {tr('main_title')}") + sep() + print() + print(f" {tr('main_prompt')}") + print() + print(f" 1 → {tr('main_opt1')}") + print(f" 2 → {tr('main_opt2')}") + print(f" 3 → {tr('main_opt3')}") + print(f" 4 → {tr('main_opt4')}") + print(f" 5 → {tr('main_opt5')}") + print(f" 6 → {tr('main_opt6')}") + print(f" 7 → {tr('main_opt7')}") + print(f" 0 → {tr('main_opt_quit')}") + print() + while True: + choix = input(tr("main_choice")).strip() + if choix in ("0", "1", "2", "3", "4", "5", "6", "7"): + break + print(tr("main_warn")) + print() + if choix == "0": + break + elif raw == "2": + if choix in ("4", "5") and generated_files: + _flash_files(generated_files, sd_dir) + # Menu de fin après copie ciblée + print() + sep("─") + print(f" {tr('after_menu')}") + print() + print(f" 1 → {tr('after_opt1')}") + print(f" 0 → {tr('press_enter').replace('...', '')}") + print() + while True: + raw2 = input(" > ").strip() + if raw2 in ("0", "1"): + break + print(tr("main_warn")) + if raw2 == "0": + choix = "0" + elif raw2 == "1": + print() + sep() + print(f" {tr('main_title')}") + sep() + print() + print(f" {tr('main_prompt')}") + print() + print(f" 1 → {tr('main_opt1')}") + print(f" 2 → {tr('main_opt2')}") + print(f" 3 → {tr('main_opt3')}") + print(f" 4 → {tr('main_opt4')}") + print(f" 5 → {tr('main_opt5')}") + print(f" 6 → {tr('main_opt6')}") + print(f" 7 → {tr('main_opt7')}") + print(f" 0 → {tr('main_opt_quit')}") + print() + while True: + choix = input(tr("main_choice")).strip() + if choix in ("0", "1", "2", "3", "4", "5", "6"): + break + print(tr("main_warn")) + print() + if choix == "0": + choix = "0" + else: + choix = "6" + + +if __name__ == "__main__": + import RetroBoxLED_gui as gui + from pathlib import Path + + script_dir = Path(__file__).parent + sd_dir = get_sd_card_dir(script_dir) + + toolkit_module = sys.modules[__name__] + app = gui.RetroBoxLEDGui(toolkit_module, sd_dir) + app.run() diff --git a/RetroBoxLED_GUItools/RetroBoxLED_gui.py b/RetroBoxLED_GUItools/RetroBoxLED_gui.py new file mode 100644 index 0000000..d69cd81 --- /dev/null +++ b/RetroBoxLED_GUItools/RetroBoxLED_gui.py @@ -0,0 +1,2323 @@ +#!/usr/bin/env python3 +import queue +import sys +import threading +import subprocess +import os +import shutil +import re +import webbrowser +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Sequence, cast + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, font as tkfont + + +@dataclass(frozen=True) +class GuiConfig: + mode_choice: str # "1".."5" + roms_root: Path + systems_selected: Optional[Sequence[Path]] + nas_user: str + nas_password: str + nas_path_is_unc: bool + + +UI_TRANSLATIONS = { + "fr": { + "sys_all_selected": "Aucun système sélectionné — tous les systèmes seront traités.", + "sys_sel_opt_all": "Tout sélectionner", + "sys_sel_opt_none": "Ne rien sélectionner", + "sys_sel_warn_empty": "Aucun système sélectionné. Cliquez sur « Tout sélectionner » ou sélectionnez au moins un système.", + "quit_app": "Quitter l'application", + "quit_app_warning_title": "Quitter et nettoyer", + "quit_app_warning": "Quitter l'application va supprimer les dossiers temporaires (sd_card). Continuer ?", + "quit_app_stopped_worker": "Traitement en cours : arrêt demandé avant la fermeture.", + "quit_app_cleanup_done": "Dossiers temporaires supprimés.", + "open_output_prompt": "Ouvrir le dossier de sortie contenant les éléments produits ?", + "open_output_yes": "Oui", + "open_output_no": "Non", + "mode6_panel_title": "Choix 6 — Copier sur la carte SD", + "mode6_btn_start": "Démarrer la copie", + "mode6_no_drives": "Aucun lecteur amovible détecté. Insérez votre carte SD puis réessayez.", + "mode6_drive_choice_none": "Aucun lecteur sélectionné — sélection du premier lecteur disponible.", + "mode6_overwrite_title": "Options", + "mode6_overwrite_yes": "Écraser les fichiers existants", + "mode6_overwrite_no": "Conserver les fichiers existants (ignorer)", + "mode6_btn_running": "Flash en cours…", + "mode6_done": "Copie sur la carte SD terminée.", + "progress_title": "Progression", + "btn_pause": "Pause", + "btn_resume": "Reprise", + "btn_skip": "Passe", + "btn_stop": "Stop", + "logs_details_title": "Détails / sortie", + "language_title": "Langue", + "mode_label": "Mode", + "roms_pick_btn": "Choisir dossier ROMs", + "start_btn": "Démarrer", + "detect_systems_btn": "Détecter systèmes (gamelist.xml)", + "systems_to_process_lbl": "Systèmes à traiter (clic pour sélectionner)", + "mode_detail_title": "Détail du mode", + "mode6_drives_title": "Lecteurs amovibles (carte SD)", + "mode6_explore_output_btn": "Explorer le dossier de sortie", + }, + "en": { + "sys_all_selected": "No system selected — all systems will be processed.", + "sys_sel_opt_all": "Select all", + "sys_sel_opt_none": "Select none", + "sys_sel_warn_empty": "No system selected. Click « Select all » or select at least one system.", + "quit_app": "Quit application", + "quit_app_warning_title": "Quit and cleanup", + "quit_app_warning": "Quitting will delete temporary folders (sd_card). Continue ?", + "quit_app_stopped_worker": "Processing is running: stop requested before closing.", + "quit_app_cleanup_done": "Temporary folders deleted.", + "open_output_prompt": "Open the output folder containing produced elements?", + "open_output_yes": "Yes", + "open_output_no": "No", + "mode6_panel_title": "Choice 6 — Copy to SD card", + "mode6_btn_start": "Start copy", + "mode6_no_drives": "No removable drive detected. Insert your SD card and try again.", + "mode6_drive_choice_none": "No drive selected — picking the first available drive.", + "mode6_overwrite_title": "Options", + "mode6_overwrite_yes": "Overwrite existing files", + "mode6_overwrite_no": "Keep existing files (skip)", + "mode6_btn_running": "Flashing…", + "mode6_done": "SD card copy completed.", + "progress_title": "Progress", + "btn_pause": "Pause", + "btn_resume": "Resume", + "btn_skip": "Skip", + "btn_stop": "Stop", + "logs_details_title": "Details / output", + "language_title": "Language", + "mode_label": "Mode", + "roms_pick_btn": "Choose ROMs folder", + "start_btn": "Start", + "detect_systems_btn": "Detect systems (gamelist.xml)", + "systems_to_process_lbl": "Systems to process (click to select)", + "mode_detail_title": "Mode details", + "mode6_drives_title": "Removable drives (SD card)", + "mode6_explore_output_btn": "Explore output folder", + }, + "es": { + "sys_all_selected": "No se seleccionó ningún sistema — se procesarán todos los sistemas.", + "sys_sel_opt_all": "Seleccionar todo", + "sys_sel_opt_none": "No seleccionar", + "sys_sel_warn_empty": "Ningún sistema seleccionado. Haz clic en « Seleccionar todo » o selecciona al menos un sistema.", + "quit_app": "Salir de la aplicación", + "quit_app_warning_title": "Salir y limpiar", + "quit_app_warning": "Al salir se borrarán las carpetas temporales (sd_card). ¿Continuar?", + "quit_app_stopped_worker": "Hay un proceso en ejecución: se solicitará la detención antes de cerrar.", + "quit_app_cleanup_done": "Carpetas temporales eliminadas.", + "open_output_prompt": "¿Abrir la carpeta de salida con los elementos generados?", + "open_output_yes": "Sí", + "open_output_no": "No", + "mode6_panel_title": "Opción 6 — Copiar a la tarjeta SD", + "mode6_btn_start": "Iniciar la copia", + "mode6_no_drives": "No se detectó ninguna unidad extraíble. Inserta tu tarjeta SD y vuelve a intentar.", + "mode6_drive_choice_none": "No se seleccionó ninguna unidad — se elige la primera disponible.", + "mode6_overwrite_title": "Opciones", + "mode6_overwrite_yes": "Sobrescribir archivos existentes", + "mode6_overwrite_no": "Conservar archivos existentes (omitir)", + "mode6_btn_running": "Flasheando…", + "mode6_done": "Copia en tarjeta SD completada.", + "progress_title": "Progreso", + "btn_pause": "Pausar", + "btn_resume": "Reanudar", + "btn_skip": "Saltar", + "btn_stop": "Parar", + "logs_details_title": "Detalles / salida", + "language_title": "Idioma", + "mode_label": "Modo", + "roms_pick_btn": "Elegir carpeta ROMs", + "start_btn": "Iniciar", + "detect_systems_btn": "Detectar sistemas (gamelist.xml)", + "systems_to_process_lbl": "Sistemas a procesar (clic para seleccionar)", + "mode_detail_title": "Detalles del modo", + "mode6_drives_title": "Unidades extraíbles (tarjeta SD)", + "mode6_explore_output_btn": "Explorar la carpeta de salida", + }, +} + + +class QueueWriter: + def __init__(self, q: "queue.Queue[str]"): + self._q = q + self._buf = "" + + def write(self, s: str) -> None: + if not s: + return + self._buf += s + while "\n" in self._buf: + line, self._buf = self._buf.split("\n", 1) + self._q.put(line + "\n") + + def flush(self) -> None: + if self._buf: + self._q.put(self._buf) + self._buf = "" + + +def _unc_root(unc_path: str) -> str: + p = unc_path.strip() + if not p.startswith("\\\\"): + return p + parts = p.split("\\") + if len(parts) < 4: + return p + return "\\\\" + parts[2] + "\\" + parts[3] + + +def _net_use_connect(unc_root: str, username: str, password: str) -> None: + cmd = ["net", "use", unc_root, f"/user:{username}", password, "/persistent:no"] + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _net_use_disconnect(unc_root: str) -> None: + cmd = ["net", "use", unc_root, "/delete", "/y"] + subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +class RetroBoxLEDGui: + def __init__(self, toolkit_module, sd_dir: Path): + self.tkmod = toolkit_module + self.sd_dir = sd_dir + + self.root = tk.Tk() + self.root.title("RetroBoxLED Toolkit - GUI") + self.root.configure(bg="#F3F3F3") + + self.style = ttk.Style(self.root) + try: + self.style.theme_use("clam") + except Exception: + pass + + self._log_q: "queue.Queue[str]" = queue.Queue() + self._worker: Optional[threading.Thread] = None + self._sys_list_adjusting = False + self._last_real_system_indices: set[int] = set() + self._last_sys_click_index: Optional[int] = None + # index réel le plus proche cliqué dans la Listbox (0..n-1), y compris index système >=3 + self._last_sys_clicked_index_any: Optional[int] = None + + self.lang_var = tk.StringVar(value="fr") + + # ── Mode 6 (flash) : clignotement + UI lecteurs (sans saisie clavier) + self._mode6_blinking = False + self._mode6_blink_job = None + self._mode6_drives = [] + self._mode6_selected_drive = None + self._mode6_overwrite = True + self._mode6_ui_frame = None + self._mode6_drive_list = None + self._mode6_btn = None + self._mode6_flash_thread: Optional[threading.Thread] = None + + self._build_top_tabs() + self._build_mode_area(self.tab_main) + self._build_progress_frame(self.tab_main) + + self._poll_logs() + + # Sérigraphie (bas à droite) + self.silk_label = tk.Label( + self.root, + text="GUI - Shan_ayA 2026", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9, "bold"), + ) + self.silk_label.place(relx=1.0, rely=1.0, anchor="se", x=-102, y=-10) + + self.root.protocol("WM_DELETE_WINDOW", self._on_close_attempt) + + # ────────────────────────────────────────────────────────────────────────── + # TOP TABS (compact logs + parameters) + # ────────────────────────────────────────────────────────────────────────── + def _build_top_tabs(self) -> None: + self.nb_top = ttk.Notebook(self.root) + self.nb_top.pack(fill="both", expand=True, padx=10, pady=8) + + self.tab_main = tk.Frame(self.nb_top, bg="#F3F3F3") + self.tab_logs = tk.Frame(self.nb_top, bg="#F3F3F3") + self.tab_params = tk.Frame(self.nb_top, bg="#F3F3F3") + self.tab_help = tk.Frame(self.nb_top, bg="#F3F3F3") + + self.nb_top.add(self.tab_main, text="Main") + self.nb_top.add(self.tab_logs, text="Logs") + self.nb_top.add(self.tab_params, text="Paramètres") + self.nb_top.add(self.tab_help, text="AIDE") + + self._build_logs_tab(self.tab_logs) + self._build_params_tab(self.tab_params) + self._build_help_tab(self.tab_help) + + def _build_logs_tab(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + + controls = tk.Frame(parent, bg="#F3F3F3", bd=0) + controls.pack(fill="x", padx=10, pady=(10, 6)) + + self.btn_pause = tk.Button( + controls, + text=ui["btn_pause"], + command=self._on_pause_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_pause.grid(row=0, column=0, padx=6, pady=4, sticky="w") + + self.btn_resume = tk.Button( + controls, + text=ui["btn_resume"], + command=self._on_resume_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_resume.grid(row=0, column=1, padx=6, pady=4, sticky="w") + + self.btn_skip = tk.Button( + controls, + text=ui["btn_skip"], + command=self._on_skip_clicked, + bg="#FF5C5C", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_skip.grid(row=0, column=2, padx=6, pady=4, sticky="w") + + self.btn_stop = tk.Button( + controls, + text=ui["btn_stop"], + command=self._on_stop_clicked, + bg="#B100FF", + fg="white", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_stop.grid(row=0, column=3, padx=6, pady=4, sticky="w") + + self.logs_details_title_lbl = tk.Label( + parent, + text=ui["logs_details_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self.logs_details_title_lbl.pack(anchor="w", padx=10, pady=(0, 6)) + + # Make logs area occupy most available space + text_frame = tk.Frame(parent, bg="#F3F3F3") + text_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + self.text = tk.Text( + text_frame, + height=1, + wrap="none", + bg="white", + fg="black", + borderwidth=3, + relief="solid", + ) + # expand + self.text.pack(side="left", fill="both", expand=True) + + scroll_y = tk.Scrollbar(text_frame, orient="vertical", command=self.text.yview) + scroll_y.pack(side="right", fill="y") + self.text.configure(yscrollcommand=scroll_y.set) + + scroll_x = tk.Scrollbar( + text_frame, orient="horizontal", command=self.text.xview + ) + scroll_x.pack(side="bottom", fill="x") + self.text.configure(xscrollcommand=scroll_x.set) + + def _build_params_tab(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + self.params_language_title_lbl = tk.Label( + parent, + text=ui["language_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.params_language_title_lbl.pack(anchor="w", padx=10, pady=(12, 6)) + + box = tk.Frame(parent, bg="#F3F3F3") + box.pack(anchor="w", padx=10, pady=6) + + langs = [("fr", "Français"), ("en", "English"), ("es", "Español")] + for i, (code, label) in enumerate(langs): + rb = tk.Radiobutton( + box, + text=label, + value=code, + variable=self.lang_var, + command=self._on_language_changed, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 10, "bold"), + ) + rb.grid(row=i, column=0, sticky="w", pady=2) + + def _build_help_tab(self, parent: tk.Frame) -> None: + self.tab_help_bg = "#F3F3F3" + container = tk.Frame(parent, bg=self.tab_help_bg) + container.pack(fill="both", expand=True, padx=10, pady=10) + + self.help_title_lbl = tk.Label( + container, + text="AIDE", + bg=self.tab_help_bg, + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.help_title_lbl.pack(anchor="w") + + text_frame = tk.Frame(container, bg=self.tab_help_bg) + text_frame.pack(fill="both", expand=True, pady=(8, 0)) + + scroll_y = tk.Scrollbar(text_frame, orient="vertical") + scroll_y.pack(side="right", fill="y") + + self.help_text = tk.Text( + text_frame, + wrap="word", + bg="white", + fg="black", + borderwidth=3, + relief="solid", + yscrollcommand=scroll_y.set, + ) + self.help_text.pack(side="left", fill="both", expand=True) + scroll_y.configure(command=self.help_text.yview) + + self._refresh_help_tab_content() + + def _refresh_help_tab_content(self) -> None: + if not getattr(self, "help_text", None): + return + + lang = ( + getattr(self, "lang_var", None).get() + if getattr(self, "lang_var", None) + else "fr" + ) + if lang == "en": + readme_name = "README.md" + elif lang == "es": + readme_name = "README.es.md" + else: + readme_name = "README.fr.md" + + if getattr(self, "help_title_lbl", None): + self.help_title_lbl.config(text=f"AIDE ({readme_name})") + + base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) + readme_path = base_dir / readme_name + try: + readme_text = readme_path.read_text(encoding="utf-8", errors="replace") + except Exception as e: + readme_text = f"Impossible de lire {readme_name} : {e}" + + # Reset + self.help_text.configure(state="normal") + self.help_text.delete("1.0", "end") + + # Tags + if not hasattr(self, "_help_url_tags"): + self._help_url_tags: dict[str, str] = {} + # tag "help_code" peut ne pas exister au premier affichage + try: + existing_font = self.help_text.tag_cget("help_code", "font") + if not existing_font: + self.help_text.tag_configure("help_code", font=("Courier New", 9)) + except tk.TclError: + self.help_text.tag_configure("help_code", font=("Courier New", 9)) + + link_re = re.compile(r"\[([^\]]+)\]\((https?://[^)]+)\)|(https?://[^\s\]]+)") + + def url_tag(url: str) -> str: + if url in self._help_url_tags: + return self._help_url_tags[url] + tag = f"help_url_{len(self._help_url_tags)}" + self._help_url_tags[url] = tag + self.help_text.tag_configure(tag, foreground="#0000EE", underline=True) + self.help_text.tag_bind( + tag, + "", + lambda e, u=url: webbrowser.open_new_tab(u), + ) + return tag + + in_code = False + for raw_line in readme_text.splitlines(True): + if raw_line.strip().startswith("```"): + in_code = not in_code + self.help_text.insert("end", raw_line, "help_code" if in_code else None) + continue + + if in_code: + self.help_text.insert("end", raw_line, "help_code") + continue + + # Outside code blocks: render markdown links + bare URLs + idx = 0 + for m in link_re.finditer(raw_line): + start, end = m.span() + if start > idx: + self.help_text.insert("end", raw_line[idx:start]) + + label = "" + url = "" + if m.group(1) and m.group(2): + label = m.group(1) + url = m.group(2) + elif m.group(3): + url = m.group(3) + label = url + + # Small cleanup for opening (avoid trailing punctuation) + url_open = url.rstrip(".,);:!?") + tag = url_tag(url_open) + self.help_text.insert("end", label, tag) + idx = end + + if idx < len(raw_line): + self.help_text.insert("end", raw_line[idx:]) + + self.help_text.configure(state="disabled") + + # ────────────────────────────────────────────────────────────────────────── + # MODE AREA + # ────────────────────────────────────────────────────────────────────────── + def _build_mode_area(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + outer = tk.Frame(parent, bg="#F3F3F3", bd=2, relief="solid", padx=10, pady=10) + outer.pack(fill="x", padx=10, pady=(10, 8)) + + outer.grid_columnconfigure(0, weight=1) + outer.grid_columnconfigure(1, weight=1) + outer.grid_columnconfigure(2, weight=1) + + # Left column: mode + ROMs path + start + left = tk.Frame(outer, bg="#F3F3F3") + left.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) + + self.mode_title_lbl = tk.Label( + left, + text=ui["mode_label"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.mode_title_lbl.pack(anchor="w") + + self.mode_var = tk.StringVar(value="1") + self._mode_radios: dict[str, tk.Radiobutton] = {} + + self._detail_templates = { + "fr": { + "1": "Mode 1 : extraction + conversion 128x32 + build games_cache + téléchargement _defaults + generation systems_cache.dat.", + "2": "Mode 2 : extraction uniquement (images depuis gamelist.xml).", + "3": "Mode 3 : conversion uniquement (PNG → 128x32).", + "4": "Mode 4 : build games_cache uniquement.", + "5": "Mode 5 : génération systems_cache.dat.", + }, + "en": { + "1": "Mode 1: extraction + 128x32 conversion + build games_cache + download _defaults + generate systems_cache.dat.", + "2": "Mode 2: extraction only (images from gamelist.xml).", + "3": "Mode 3: conversion only (PNG → 128x32).", + "4": "Mode 4: build games_cache only.", + "5": "Mode 5: generate systems_cache.dat.", + }, + "es": { + "1": "Modo 1: extracción + conversión 128x32 + build games_cache + descargar _defaults + generate systems_cache.dat.", + "2": "Modo 2: extracción solo (imágenes desde gamelist.xml).", + "3": "Modo 3: conversión solo (PNG → 128x32).", + "4": "Modo 4: build games_cache solo.", + "5": "Modo 5: generate systems_cache.dat.", + }, + } + + # Titles from toolkit translations + modes = [ + ("1", self.tkmod.tr("mode1_title")), + ("2", self.tkmod.tr("mode2_title")), + ("3", self.tkmod.tr("mode3_title")), + ("4", self.tkmod.tr("mode4_title")), + ("5", self.tkmod.tr("sysc_title")), + ] + + for m, label in modes: + rb = tk.Radiobutton( + left, + text=label, + variable=self.mode_var, + value=m, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 10, "bold"), + wraplength=240, + justify="left", + command=self._on_mode_changed, + ) + rb.pack(anchor="w", pady=2) + self._mode_radios[m] = rb + + # ROMs folder + path_box = tk.Frame(left, bg="#F3F3F3", bd=2, relief="solid", padx=8, pady=8) + path_box.pack(fill="x", pady=(12, 0)) + + self.roms_path_var = tk.StringVar(value="") + + self.btn_pick_roms = tk.Button( + path_box, + text=ui["roms_pick_btn"], + command=self._pick_roms_directory, + bg="#FFFFFF", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=6, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_pick_roms.pack(fill="x") + + tk.Label( + path_box, + textvariable=self.roms_path_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9), + wraplength=260, + justify="left", + ).pack(anchor="w", pady=(8, 0)) + + self.btn_start = tk.Button( + left, + text=ui["start_btn"], + command=self._on_start_clicked, + bg="#00D084", + fg="black", + bd=2, + relief="solid", + padx=12, + pady=8, + font=("TkDefaultFont", 12, "bold"), + ) + self.btn_start.pack(fill="x", pady=(12, 0)) + + self.btn_quit_app = tk.Button( + left, + text=( + self.tkmod.tr("main_opt_quit") if hasattr(self.tkmod, "tr") else "Quit" + ), + command=self._on_quit_app_clicked, + bg="#FF5C5C", + fg="black", + bd=2, + relief="solid", + padx=12, + pady=6, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_quit_app.pack(fill="x", pady=(8, 0)) + + # Middle column: systems detection (only for modes 1/2) + self.middle = tk.Frame(outer, bg="#F3F3F3", bd=0) + self.middle.grid(row=0, column=1, sticky="nsew", padx=10) + + self.btn_detect_systems = tk.Button( + self.middle, + text=ui["detect_systems_btn"], + command=self._on_detect_systems_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=8, + pady=6, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_detect_systems.pack(fill="x") + + self.systems_to_process_lbl = tk.Label( + self.middle, + text=ui["systems_to_process_lbl"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self.systems_to_process_lbl.pack(anchor="w", pady=(10, 6)) + + box = tk.Frame(self.middle, bg="#F3F3F3") + box.pack(fill="both", expand=True) + + self.sys_list = tk.Listbox( + box, + selectmode="multiple", + height=7, + width=28, + bg="white", + fg="black", + borderwidth=3, + relief="solid", + ) + self.sys_list.pack(side="left", fill="both", expand=True) + + scroll = tk.Scrollbar(box, orient="vertical", command=self.sys_list.yview) + scroll.pack(side="right", fill="y") + self.sys_list.configure(yscrollcommand=scroll.set) + self.sys_list.bind( + "<>", + self._on_systems_listbox_select_changed, + ) + self.sys_list.bind( + "", + self._on_sys_list_button1_clicked, + ) + + # Right column: mode details + self.right = tk.Frame(outer, bg="#F3F3F3") + self.right.grid(row=0, column=2, sticky="nsew", padx=(10, 0)) + + self.mode_detail_title_lbl = tk.Label( + self.right, + text=ui["mode_detail_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.mode_detail_title_lbl.pack(anchor="w", pady=(0, 6)) + + self.mode_desc_var = tk.StringVar(value="") + self.mode_desc_label = tk.Label( + self.right, + textvariable=self.mode_desc_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10), + wraplength=270, + justify="left", + ) + self.mode_desc_label.pack(anchor="w", fill="x") + + # ── Mode 6 panel (hidden until previous pipeline is finished) + self._mode6_ui_frame = tk.Frame( + self.right, bg="#F3F3F3", bd=2, relief="solid", padx=8, pady=8 + ) + self._mode6_ui_frame.pack(fill="x", pady=(12, 0)) + self._mode6_ui_frame.pack_forget() + + self._mode6_panel_title_lbl = tk.Label( + self._mode6_ui_frame, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_panel_title" + ], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 11, "bold"), + ) + self._mode6_panel_title_lbl.pack(anchor="w") + + self._mode6_drives_title_lbl = tk.Label( + self._mode6_ui_frame, + text=ui["mode6_drives_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9, "bold"), + ) + self._mode6_drives_title_lbl.pack(anchor="w", pady=(8, 4)) + + drives_box = tk.Frame(self._mode6_ui_frame, bg="#F3F3F3") + drives_box.pack(fill="x") + + self._mode6_drive_list = tk.Listbox( + drives_box, + selectmode="browse", + height=5, + width=26, + bg="white", + fg="black", + borderwidth=3, + relief="solid", + ) + self._mode6_drive_list.pack(side="left", fill="both", expand=True) + + scroll = tk.Scrollbar( + drives_box, orient="vertical", command=self._mode6_drive_list.yview + ) + scroll.pack(side="right", fill="y") + self._mode6_drive_list.configure(yscrollcommand=scroll.set) + + self._mode6_overwrite_var = tk.BooleanVar(value=True) + self._mode6_overwrite_title_lbl = tk.Label( + self._mode6_ui_frame, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_overwrite_title" + ], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self._mode6_overwrite_title_lbl.pack(anchor="w", pady=(10, 4)) + + opt_box = tk.Frame(self._mode6_ui_frame, bg="#F3F3F3") + opt_box.pack(anchor="w") + + self._mode6_rb_overwrite_yes = tk.Radiobutton( + opt_box, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_overwrite_yes" + ], + variable=self._mode6_overwrite_var, + value=True, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 9, "bold"), + anchor="w", + ) + self._mode6_rb_overwrite_yes.pack(anchor="w") + + self._mode6_rb_overwrite_no = tk.Radiobutton( + opt_box, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_overwrite_no" + ], + variable=self._mode6_overwrite_var, + value=False, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 9, "bold"), + anchor="w", + ) + self._mode6_rb_overwrite_no.pack(anchor="w") + + self._mode6_btn = tk.Button( + self._mode6_ui_frame, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_btn_start" + ], + command=self._on_mode6_button_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=6, + font=("TkDefaultFont", 11, "bold"), + state="disabled", + ) + self._mode6_btn.pack(fill="x", pady=(10, 0)) + + # Bouton "Explorer le dossier de sortie" (activé une fois la copie terminée) + self._mode6_explore_output_btn = tk.Button( + self._mode6_ui_frame, + text=ui["mode6_explore_output_btn"], + command=lambda: os.startfile( + str(self.sd_dir) + ), # type: ignore[attr-defined] + bg="#FFFFFF", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=6, + font=("TkDefaultFont", 10, "bold"), + state="disabled", + ) + self._mode6_explore_output_btn.pack(fill="x", pady=(0, 6)) + + self._on_mode_changed() + + # ────────────────────────────────────────────────────────────────────────── + # language + mode logic + # ────────────────────────────────────────────────────────────────────────── + def _set_toolkit_language(self, lang: str) -> None: + # toolkit has TRANSLATIONS + global T + try: + self.tkmod.T = self.tkmod.TRANSLATIONS[lang] + except Exception: + return + + def _on_language_changed(self) -> None: + lang = self.lang_var.get() + if lang not in ("fr", "en", "es"): + return + self._set_toolkit_language(lang) + + ui = self._get_ui_t() + + # update titles for radios (toolkit) + self._mode_radios["1"].config(text=self.tkmod.tr("mode1_title")) + self._mode_radios["2"].config(text=self.tkmod.tr("mode2_title")) + self._mode_radios["3"].config(text=self.tkmod.tr("mode3_title")) + self._mode_radios["4"].config(text=self.tkmod.tr("mode4_title")) + self._mode_radios["5"].config(text=self.tkmod.tr("sysc_title")) + + # update main UI labels/buttons (ours) + if getattr(self, "params_language_title_lbl", None): + self.params_language_title_lbl.config(text=ui["language_title"]) + + if getattr(self, "mode_title_lbl", None): + self.mode_title_lbl.config(text=ui["mode_label"]) + + if getattr(self, "btn_pick_roms", None): + self.btn_pick_roms.config(text=ui["roms_pick_btn"]) + + if getattr(self, "btn_start", None): + self.btn_start.config(text=ui["start_btn"]) + + if getattr(self, "btn_detect_systems", None): + self.btn_detect_systems.config(text=ui["detect_systems_btn"]) + + if getattr(self, "systems_to_process_lbl", None): + self.systems_to_process_lbl.config(text=ui["systems_to_process_lbl"]) + + if getattr(self, "mode_detail_title_lbl", None): + self.mode_detail_title_lbl.config(text=ui["mode_detail_title"]) + + if getattr(self, "progress_title_lbl", None): + self.progress_title_lbl.config(text=ui["progress_title"]) + + # Boutons Logs + if getattr(self, "btn_pause", None): + self.btn_pause.config(text=ui["btn_pause"]) + if getattr(self, "btn_resume", None): + self.btn_resume.config(text=ui["btn_resume"]) + if getattr(self, "btn_skip", None): + self.btn_skip.config(text=ui["btn_skip"]) + if getattr(self, "btn_stop", None): + self.btn_stop.config(text=ui["btn_stop"]) + + # Boutons Progress (onglet Main) + if getattr(self, "btn_pause_progress", None): + self.btn_pause_progress.config(text=ui["btn_pause"]) + if getattr(self, "btn_resume_progress", None): + self.btn_resume_progress.config(text=ui["btn_resume"]) + if getattr(self, "btn_skip_progress", None): + self.btn_skip_progress.config(text=ui["btn_skip"]) + if getattr(self, "btn_stop_progress", None): + self.btn_stop_progress.config(text=ui["btn_stop"]) + + if getattr(self, "btn_quit_app", None): + # bouton "Quitter" : tkmod.tr() fournit le texte localisé + try: + self.btn_quit_app.config(text=self.tkmod.tr("main_opt_quit")) + except Exception: + pass + + if getattr(self, "logs_details_title_lbl", None): + self.logs_details_title_lbl.config(text=ui["logs_details_title"]) + + if getattr(self, "_mode6_drives_title_lbl", None): + self._mode6_drives_title_lbl.config(text=ui["mode6_drives_title"]) + + if getattr(self, "_mode6_explore_output_btn", None): + self._mode6_explore_output_btn.config(text=ui["mode6_explore_output_btn"]) + + # mode details / titre panneau + if getattr(self, "mode_detail_title_lbl", None): + self.mode_detail_title_lbl.config(text=ui["mode_detail_title"]) + + # update description + mode6 texts + self._update_mode_desc() + self._sync_mode6_texts() + + # refresh help tab (README language + links) + self._refresh_help_tab_content() + + def _update_mode_desc(self) -> None: + mode = self.mode_var.get() + lang = self.lang_var.get() + self.mode_desc_var.set( + self._detail_templates.get(lang, self._detail_templates["fr"]).get(mode, "") + ) + + def _on_mode_changed(self) -> None: + mode = self.mode_var.get() + + if mode in ("1", "2"): + self.middle.grid() + self.btn_detect_systems.config(state="normal") + # auto-detect if possible + self._maybe_autodetect_systems() + else: + self.middle.grid_remove() + + self._update_mode_desc() + + def _maybe_autodetect_systems(self) -> None: + path_str = self.roms_path_var.get().strip() + if not path_str: + return + roms_root = Path(path_str) + if not roms_root.exists(): + return + + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + is_unc = str(roms_root).startswith("\\\\") + + try: + systems = self._find_systems(roms_root) + except Exception: + if is_unc: + self._show_credentials_if_needed(True) + messagebox.showwarning( + "Attention", + "Accès réseau impossible (lecture gamelist.xml). Renseignez NAS user/mdp, puis cliquez « Détecter systèmes ».", + ) + return + raise + + self.sys_list.delete(0, "end") + + # 0="Tout sélectionner", 1="Ne rien sélectionner", 2="" separator + # Note: tk.Listbox ne supporte pas itemconfig(font=...), donc on ne force pas l'italique ici. + self.sys_list.insert("end", ui["sys_sel_opt_all"]) + self.sys_list.insert("end", ui["sys_sel_opt_none"]) + # Ne pas insérer "" : certains thèmes/implémentations peuvent rendre la hauteur peu fiable. + self.sys_list.insert("end", " ") + + for sys_path in systems: + self.sys_list.insert("end", sys_path.name) + + # Garantir l'affichage depuis le haut (sinon on peut “ne voir” qu'un seul item). + try: + self.sys_list.yview_moveto(0.0) + except Exception: + pass + + def _find_systems(self, roms_root: Path) -> list[Path]: + systems: list[Path] = [] + for d in roms_root.iterdir(): + if d.is_dir() and (d / "gamelist.xml").exists(): + systems.append(d) + systems.sort(key=lambda p: p.name.lower()) + return systems + + def _on_sys_list_button1_clicked(self, _event: object = None) -> None: + # capture l'intention (index cliqué: 0/1/2) et la sélection "réelle" (indices >= 3 : systèmes) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + try: + if _event is not None and hasattr(_event, "y"): + click_index = self.sys_list.nearest(getattr(_event, "y")) + if isinstance(click_index, int): + self._last_sys_clicked_index_any = click_index + if click_index in (0, 1, 2): + self._last_sys_click_index = click_index + except Exception: + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + + try: + selected = list(self.sys_list.curselection()) + self._last_real_system_indices = {i for i in selected if i >= 3} + except Exception: + self._last_real_system_indices = set() + + def _on_systems_listbox_select_changed(self, _event: object = None) -> None: + if self._sys_list_adjusting: + return + + total_items = self.sys_list.size() + # Pas de place pour sentinelles + séparateur + systèmes + if total_items <= 3: + return + + try: + self._sys_list_adjusting = True + + selected = list(self.sys_list.curselection()) + selected_set = set(selected) + + total_systems = total_items - 3 + systems_start = 3 + systems_end_exclusive = systems_start + total_systems + + # Si on a cliqué un système réel (index >= 3), on veut autoriser le mode manuel : + # - on retire uniquement les sentinelles + # - on ne force jamais "Tout/Ne rien" + if ( + self._last_sys_clicked_index_any is not None + and self._last_sys_clicked_index_any >= systems_start + ): + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + # Cas délicat : au clic, Tk peut laisser transitoirement 0 et 1 sélectionnés. + # On tranche alors selon l'index réellement cliqué (via _on_sys_list_button1_clicked). + if 0 in selected_set and 1 in selected_set: + intent = self._last_sys_click_index + if intent == 0: + # "Tout sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + for i in range(systems_start, systems_end_exclusive): + self.sys_list.selection_set(i) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + if intent == 1: + # "Ne rien sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(1) + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + # Sinon (intention None ou clic sur une ligne système) => mode manuel : + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + if 1 in selected_set: + # "Ne rien sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(1) + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + return + + if 0 in selected_set: + # "Tout sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + for i in range(systems_start, systems_end_exclusive): + self.sys_list.selection_set(i) + self._last_sys_click_index = None + return + + # Sélection manuelle : on retire sentinelles 0/1 (et on s'assure que la ligne vide n'est pas sélectionnée) + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + + # Si l'utilisateur a sélectionné tous les systèmes individuellement => on met "Tout sélectionner" + real_selected = [i for i in selected if i >= systems_start] + if len(real_selected) == total_systems and total_systems > 0: + self.sys_list.selection_set(0) + + finally: + self._sys_list_adjusting = False + + # ────────────────────────────────────────────────────────────────────────── + # inputs + # ────────────────────────────────────────────────────────────────────────── + def _pick_roms_directory(self) -> None: + p = filedialog.askdirectory(title="Choisir dossier ROMs") + if not p: + return + self.roms_path_var.set(p) + + is_unc = str(p).startswith("\\\\") + + # On essaie de peupler la liste des systèmes directement. + # Si l'accès réseau échoue (gamelist.xml), on affichera user/mdp plus tard. + if self.mode_var.get() in ("1", "2"): + self._on_mode_changed() + + def _get_roms_root_or_warn(self) -> Optional[Path]: + path_str = self.roms_path_var.get().strip() + if not path_str: + messagebox.showwarning("Attention", "Choisis un dossier ROMs d'abord.") + return None + roms_root = Path(path_str) + if not roms_root.exists(): + messagebox.showwarning("Attention", "Dossier ROMs introuvable.") + return None + return roms_root + + def _on_detect_systems_clicked(self) -> None: + roms_root = self._get_roms_root_or_warn() + if roms_root is None: + return + + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + is_unc = str(roms_root).startswith("\\\\") + + try: + systems = self._find_systems(roms_root) + except Exception: + if is_unc: + self._show_credentials_if_needed(True) + messagebox.showwarning( + "Attention", + "Accès réseau impossible (lecture gamelist.xml). Renseignez NAS user/mdp, puis relancez « Détecter systèmes ».", + ) + return + raise + + self.sys_list.delete(0, "end") + + # 0="Tout sélectionner", 1="Ne rien sélectionner", 2="" separator + # Note: tk.Listbox ne supporte pas itemconfig(font=...), donc on ne force pas l'italique ici. + self.sys_list.insert("end", ui["sys_sel_opt_all"]) + self.sys_list.insert("end", ui["sys_sel_opt_none"]) + # Ne pas insérer "" : certains thèmes/implémentations peuvent rendre la hauteur peu fiable. + self.sys_list.insert("end", " ") + + for sys_path in systems: + self.sys_list.insert("end", sys_path.name) + + # Garantir l'affichage depuis le haut (sinon on peut “ne voir” qu'un seul item). + try: + self.sys_list.yview_moveto(0.0) + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # NAS credentials (only if UNC at start) + # ────────────────────────────────────────────────────────────────────────── + def _ensure_credentials_widgets(self) -> None: + if hasattr(self, "creds_frame"): + return + + # credentials panel under the mode details (right panel) + self.creds_frame = tk.Frame( + self.right, bg="#F3F3F3", bd=2, relief="solid", padx=8, pady=8 + ) + self.creds_frame.pack(fill="x", pady=(12, 0)) + + tk.Label( + self.creds_frame, + text="Identifiants NAS (UNC)", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 11, "bold"), + ).grid(row=0, column=0, columnspan=2, sticky="w") + + tk.Label( + self.creds_frame, + text="NAS user", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ).grid(row=1, column=0, sticky="w", pady=(8, 2)) + + self.ent_nas_user = tk.Entry(self.creds_frame, width=22) + self.ent_nas_user.grid(row=2, column=0, sticky="w", pady=2) + + tk.Label( + self.creds_frame, + text="NAS password", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ).grid(row=1, column=1, sticky="w", pady=(8, 2), padx=(10, 0)) + + self.ent_nas_pass = tk.Entry(self.creds_frame, width=22, show="*") + self.ent_nas_pass.grid(row=2, column=1, sticky="w", pady=2) + + def _show_credentials_if_needed(self, is_unc: bool) -> None: + self._ensure_credentials_widgets() + if is_unc: + self.creds_frame.pack(fill="x", pady=(12, 0)) + else: + self.creds_frame.pack_forget() + + def _get_nas_credentials_or_warn(self) -> Optional[tuple[str, str]]: + self._ensure_credentials_widgets() + user = self.ent_nas_user.get().strip() + pwd = self.ent_nas_pass.get() + if not user or not pwd: + # Pas de messagebox au clic "Démarrer" : l'utilisateur doit saisir + # les identifiants dans le panneau NAS. + try: + if not user: + self.ent_nas_user.focus_set() + else: + self.ent_nas_pass.focus_set() + except Exception: + pass + return None + return user, pwd + + # ────────────────────────────────────────────────────────────────────────── + # progress + # ────────────────────────────────────────────────────────────────────────── + def _build_progress_frame(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + + frm = tk.Frame(parent, bg="#F3F3F3", bd=2, relief="solid", padx=10, pady=10) + frm.pack(fill="x", padx=10, pady=(0, 10)) + # Fixe la largeur de la colonne texte (évite que les labels longs "poussent" la barre boutons) + frm.grid_columnconfigure(0, minsize=420, weight=0) + frm.grid_columnconfigure(1, weight=1) + + self.progress_title_lbl = tk.Label( + frm, + text=ui["progress_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 11, "bold"), + ) + self.progress_title_lbl.grid(row=0, column=0, sticky="w") + + self.progress_var = tk.StringVar(value="—") + self.progress_sub_var = tk.StringVar(value="") + self.progress_pct_var = tk.StringVar(value="0%") + + # Largeur FIXE (empêche Tk d'élargir la colonne si le texte est long) + tk.Label( + frm, + textvariable=self.progress_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + width=55, + wraplength=9999, + justify="left", + anchor="w", + ).grid(row=1, column=0, sticky="w", pady=4) + + tk.Label( + frm, + textvariable=self.progress_sub_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9, "bold"), + width=55, + # Ne jamais wrapper : sinon la hauteur bouge et repousse la barre/boutons. + wraplength=9999, + justify="left", + anchor="w", + ).grid(row=2, column=0, sticky="w") + + self.progress = ttk.Progressbar( + frm, orient="horizontal", length=600, mode="determinate" + ) + self.progress.grid(row=0, column=1, rowspan=3, padx=10, sticky="we") + self.progress.configure(maximum=100) + + # Affiche un % centré sur la barre (ne déplace pas la mise en page) + self.progress_pct_label = tk.Label( + frm, + textvariable=self.progress_pct_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self.progress_pct_label.place( + in_=self.progress, relx=0.5, rely=0.5, anchor="center" + ) + + # Buttons under progression (main tab) + controls = tk.Frame(frm, bg="#F3F3F3") + controls.grid(row=3, column=0, columnspan=2, sticky="we", pady=(10, 0)) + + self.btn_pause_progress = tk.Button( + controls, + text=ui["btn_pause"], + command=self._on_pause_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_pause_progress.grid(row=0, column=0, padx=6, pady=4, sticky="w") + + self.btn_resume_progress = tk.Button( + controls, + text=ui["btn_resume"], + command=self._on_resume_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_resume_progress.grid(row=0, column=1, padx=6, pady=4, sticky="w") + + self.btn_skip_progress = tk.Button( + controls, + text=ui["btn_skip"], + command=self._on_skip_clicked, + bg="#FF5C5C", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_skip_progress.grid(row=0, column=2, padx=6, pady=4, sticky="w") + + self.btn_stop_progress = tk.Button( + controls, + text=ui["btn_stop"], + command=self._on_stop_clicked, + bg="#B100FF", + fg="white", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_stop_progress.grid(row=0, column=3, padx=6, pady=4, sticky="w") + + def _progress_cb_ui(self, kind: str, idx: int, total: int, label: str = "") -> None: + total = max(total, 1) + pct = int((idx / total) * 100) + self.progress.configure(maximum=100, value=pct) + self.progress_pct_var.set(f"{pct}%") + + # Ligne 1/2 stables : + # - "extraction" : LINE 1 = système, LINE 2 vide + # - "extraction_imgs" : LINE 2 seulement (pas de modification de la ligne 1) + # - autres kinds : LINE 1 = étape, LINE 2 = label tronqué + if kind == "extraction": + # Ligne 1 : système (sans idx/total afin que l'affichage ne dérive pas) + sys_name = label or "" + max_sys_len = 38 + if len(sys_name) > max_sys_len: + sys_name = sys_name[: max_sys_len - 1] + "…" + + title = "Extraction" + if sys_name: + title = f"{title} — {sys_name}" + + self._last_progress_detail = title + self.progress_var.set(title) + + # Ligne 2 : vide, sera remplie par extraction_imgs + self.progress_sub_var.set("") + return + + if kind == "extraction_imgs": + # Ligne 2 uniquement : images copiées/total + fichier en cours (tronqué) + shown = label or "" + max_len = 55 + if len(shown) > max_len: + shown = shown[: max_len - 1] + "…" + self.progress_sub_var.set(shown) + return + + # Autres étapes + if kind == "conversion": + title = f"Conversion {idx}/{total}" + elif kind == "cache": + title = f"Cache {idx}/{total}" + elif kind == "download_defaults": + title = f"Download defaults {idx}/{total}" + elif kind == "systems_cache": + title = f"systems_cache {idx}/{total}" + else: + title = f"{kind} {idx}/{total}" + + shown = label or "" + max_len = 55 + if len(shown) > max_len: + shown = shown[: max_len - 1] + "…" + + self._last_progress_detail = title + self.progress_var.set(title) + self.progress_sub_var.set(shown) + return + + def _progress_cb(self, kind: str, idx: int, total: int, label: str = "") -> None: + self.root.after(0, self._progress_cb_ui, kind, idx, total, label) + + # ────────────────────────────────────────────────────────────────────────── + # logs polling + # ────────────────────────────────────────────────────────────────────────── + def _poll_logs(self) -> None: + try: + while True: + line = self._log_q.get_nowait() + self._append_log(line) + except queue.Empty: + pass + self.root.after(100, self._poll_logs) + + def _append_log(self, s: str) -> None: + self.text.insert("end", s) + self.text.see("end") + try: + if int(self.text.index("end-1c").split(".")[0]) > 400: + self.text.delete("1.0", "200.0") + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # pause/skip/stop + # ────────────────────────────────────────────────────────────────────────── + def _on_pause_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_pause, "pause") + + def _on_resume_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_resume, "resume") + + def _on_skip_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_skip, "skip") + + def _on_stop_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_stop, "stop") + + def _safe_invoke_pause(self, fn: Callable[[], None], kind: str) -> None: + try: + fn() + # Ne pas écraser la progression détaillée (elle vient des progress_cb du worker). + # On ne met un label générique que si aucun détail n'a jamais été affiché. + detail = getattr(self, "_last_progress_detail", None) + if not detail: + self.progress_var.set(f"Commande: {kind}") + except Exception as e: + messagebox.showerror("Erreur", str(e)) + + # ────────────────────────────────────────────────────────────────────────── + # worker + # ────────────────────────────────────────────────────────────────────────── + def _on_start_clicked(self) -> None: + if self._worker and self._worker.is_alive(): + messagebox.showwarning("Attention", "Traitement déjà en cours.") + return + + roms_root = self._get_roms_root_or_warn() + if roms_root is None: + return + + mode = self.mode_var.get() + is_unc = str(roms_root).startswith("\\\\") + + systems_selected: Optional[list[Path]] = None + nas_user = "" + nas_password = "" + connect_unc_for_worker = False + + # Pour modes 1/2 : on lit gamelist.xml pour construire la liste système. + # On ne demande NAS user/mdp que si la lecture échoue. + if mode in ("1", "2"): + try: + systems_all = self._find_systems(roms_root) + except Exception: + if not is_unc: + raise + # Accès UNC nécessaire + self._show_credentials_if_needed(True) + creds = self._get_nas_credentials_or_warn() + if creds is None: + return + nas_user, nas_password = creds + connect_unc_for_worker = True + + unc = str(roms_root) + net_root = _unc_root(unc) + try: + _net_use_connect(net_root, nas_user, nas_password) + systems_all = self._find_systems(roms_root) + finally: + _net_use_disconnect(net_root) + + if not systems_all: + messagebox.showwarning( + "Attention", + "Aucun système détecté (gamelist.xml introuvable).", + ) + return + + selected_indices = list(self.sys_list.curselection()) + + # Sentinel mapping: + # 0="Tout sélectionner" (italique) + # 1="Ne rien sélectionner" (italique) + # 2="" separator + # systèmes à partir de 3 + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + + if 0 in selected_indices: + systems_selected = systems_all + else: + real_indices = [i - 3 for i in selected_indices if i >= 3] + systems_selected = [ + systems_all[i] for i in real_indices if 0 <= i < len(systems_all) + ] + + # Si aucun système réel n’est sélectionné => avertir + return (pas de traitement) + if not systems_selected: + messagebox.showwarning("Attention", ui["sys_sel_warn_empty"]) + try: + self.sys_list.focus_set() + except Exception: + pass + return + + # Pour les autres modes, on conserve le comportement précédent (mais sans forcer NAS user/mdp ici). + cfg = GuiConfig( + mode_choice=mode, + roms_root=roms_root, + systems_selected=cast(Optional[Sequence[Path]], systems_selected), + nas_user=nas_user, + nas_password=nas_password, + nas_path_is_unc=(is_unc and connect_unc_for_worker), + ) + + self.progress_var.set("Démarrage...") + self.progress.configure(maximum=100, value=0) + self.text.delete("1.0", "end") + + self._worker = threading.Thread( + target=self._worker_main, args=(cfg,), daemon=True + ) + self._worker.start() + + def _worker_main(self, cfg: GuiConfig) -> None: + toolkit = self.tkmod + net_root = None + + if cfg.nas_path_is_unc: + unc = str(cfg.roms_root) + net_root = _unc_root(unc) + try: + _net_use_connect(net_root, cfg.nas_user, cfg.nas_password) + print(f"[NAS] net use connect: {net_root}") + except Exception as e: + print(f"[NAS] net use connect failed: {e}") + net_root = None + + log_writer = QueueWriter(self._log_q) + old_stdout = sys.stdout + old_stderr = sys.stderr + try: + sys.stdout = log_writer # type: ignore[assignment] + sys.stderr = log_writer # type: ignore[assignment] + + try: + toolkit.ensure_dependencies() + except Exception: + pass + + toolkit.PAUSE.request_resume() + + mode = cfg.mode_choice + if mode == "1": + self._pipeline_mode_1(toolkit, cfg) + elif mode == "2": + self._pipeline_mode_2(toolkit, cfg) + elif mode == "3": + self._pipeline_mode_3(toolkit, cfg) + elif mode == "4": + self._pipeline_mode_4(toolkit, cfg) + elif mode == "5": + self._pipeline_mode_5(toolkit, cfg) + else: + print(f"Mode inconnu: {mode}") + + # Si l'utilisateur n'a pas demandé Stop, alors on "révèle" le choix 6 + # avec clignotement (via root.after car on est dans un thread worker). + if not toolkit.PAUSE.should_stop(): + self.root.after(0, self._on_pipeline_finished) + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + if net_root: + try: + _net_use_disconnect(net_root) + print(f"[NAS] net use disconnect: {net_root}") + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # pipelines + # ────────────────────────────────────────────────────────────────────────── + def _pipeline_mode_1(self, toolkit, cfg: GuiConfig) -> None: + sd_dir = self.sd_dir + sd_dir.mkdir(parents=True, exist_ok=True) + toolkit.prepare_sd_card(sd_dir, interactive=False) + + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + tag_configs = [("logo", "")] + selected_systems = cfg.systems_selected + + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = toolkit.run_extraction( + cfg.roms_root, + systems_out, + tag_configs, + log_file, + selected_systems=selected_systems, + progress_cb=self._progress_cb, + listen_keyboard=False, + ) + toolkit._write_log(log_file, cfg.roms_root, grand) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + toolkit.run_conversion( + systems_out, progress_cb=self._progress_cb, listen_keyboard=False + ) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + toolkit.build_cache(systems_out, sd_dir, progress_cb=self._progress_cb) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + toolkit.download_defaults( + sd_dir, + progress_cb=self._progress_cb, + listen_keyboard=False, + replace_existing=True, + download_missing=True, + ) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + sysc_out = sd_dir / "systems_cache.dat" + toolkit.build_systems_cache( + systems_out, sysc_out, progress_cb=self._progress_cb + ) + + print("[GUI] DONE mode 1") + + def _pipeline_mode_2(self, toolkit, cfg: GuiConfig) -> None: + sd_dir = self.sd_dir + sd_dir.mkdir(parents=True, exist_ok=True) + toolkit.prepare_sd_card(sd_dir, interactive=False) + + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + tag_configs = [("logo", "")] + selected_systems = cfg.systems_selected + + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = toolkit.run_extraction( + cfg.roms_root, + systems_out, + tag_configs, + log_file, + selected_systems=selected_systems, + progress_cb=self._progress_cb, + listen_keyboard=False, + ) + toolkit._write_log(log_file, cfg.roms_root, grand) + + print("[GUI] DONE mode 2") + + def _pipeline_mode_3(self, toolkit, cfg: GuiConfig) -> None: + systems_out = self.sd_dir / "systems" + if not systems_out.exists(): + print("[GUI] Mode 3: dossier systems/ introuvable (dans sd_card/systems).") + return + + toolkit.run_conversion( + systems_out, progress_cb=self._progress_cb, listen_keyboard=False + ) + print("[GUI] DONE mode 3") + + def _pipeline_mode_4(self, toolkit, cfg: GuiConfig) -> None: + systems_out = self.sd_dir / "systems" + if not systems_out.exists(): + print("[GUI] Mode 4: dossier systems/ introuvable (dans sd_card/systems).") + return + + toolkit.build_cache(systems_out, self.sd_dir, progress_cb=self._progress_cb) + print("[GUI] DONE mode 4") + + def _pipeline_mode_5(self, toolkit, cfg: GuiConfig) -> None: + systems_out = self.sd_dir / "systems" + sysc_out = self.sd_dir / "systems_cache.dat" + if not systems_out.exists(): + print("[GUI] Mode 5: dossier systems/ introuvable.") + return + + toolkit.build_systems_cache( + systems_out, sysc_out, progress_cb=self._progress_cb + ) + print("[GUI] DONE mode 5") + + # ────────────────────────────────────────────────────────────────────────── + # mode 6 + fin de traitement + actions finaux + # ────────────────────────────────────────────────────────────────────────── + def _get_ui_t(self) -> dict[str, str]: + lang = self.lang_var.get() + return UI_TRANSLATIONS.get(lang, UI_TRANSLATIONS["fr"]) + + def _cleanup_sd_dir(self) -> None: + try: + if self.sd_dir.exists(): + shutil.rmtree(self.sd_dir) + except Exception: + pass + + def _refresh_mode6_drives(self) -> None: + try: + drives = self.tkmod._list_removable_drives() # type: ignore[attr-defined] + except Exception: + drives = [] + self._mode6_drives = list(drives) + self._mode6_drive_list.delete(0, "end") + for i, (letter, label, size) in enumerate(self._mode6_drives): + self._mode6_drive_list.insert( + "end", f"{i+1} → {letter}\\\\ [{label}] {size}" + ) + if self._mode6_drives: + self._mode6_drive_list.selection_set(0) + else: + # keep empty + pass + + def _sync_mode6_texts(self) -> None: + ui = self._get_ui_t() + + if self._mode6_panel_title_lbl: + self._mode6_panel_title_lbl.config(text=ui["mode6_panel_title"]) + + if self._mode6_overwrite_title_lbl: + self._mode6_overwrite_title_lbl.config(text=ui["mode6_overwrite_title"]) + + if getattr(self, "_mode6_rb_overwrite_yes", None): + self._mode6_rb_overwrite_yes.config(text=ui["mode6_overwrite_yes"]) + + if getattr(self, "_mode6_rb_overwrite_no", None): + self._mode6_rb_overwrite_no.config(text=ui["mode6_overwrite_no"]) + + if self._mode6_btn: + self._mode6_btn.config(text=ui["mode6_btn_start"]) + + def _start_mode6_blinking(self) -> None: + if not self._mode6_ui_frame or not self._mode6_btn: + return + if not self._mode6_ui_frame.winfo_ismapped(): + self._mode6_ui_frame.pack(fill="x", pady=(12, 0)) + self._mode6_blinking = True + + self._refresh_mode6_drives() + self._mode6_btn.config(state="normal") + self._mode6_btn.config(text=self._get_ui_t()["mode6_btn_start"]) + + def _tick() -> None: + if not self._mode6_blinking: + return + # toggle bg for blink + current = self._mode6_btn.cget("bg") + new_bg = "#FFFFFF" if current != "#FFFFFF" else "#FFD400" + self._mode6_btn.config(bg=new_bg) + self._mode6_blink_job = self.root.after(400, _tick) + + self._mode6_blink_job = self.root.after(250, _tick) + + def _stop_mode6_blinking(self) -> None: + self._mode6_blinking = False + if self._mode6_blink_job is not None: + try: + self.root.after_cancel(self._mode6_blink_job) + except Exception: + pass + self._mode6_blink_job = None + if self._mode6_btn: + self._mode6_btn.config(bg="#FFD400") + + def _center_toplevel(self, win: tk.Toplevel) -> None: + # Centre la popup au milieu de la fenêtre principale + try: + self.root.update_idletasks() + win.update_idletasks() + + w = win.winfo_width() + h = win.winfo_height() + if w <= 1 or h <= 1: + return + + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + win.geometry(f"+{x}+{y}") + except Exception: + pass + + def _on_pipeline_finished(self) -> None: + # habilite mode 6 + clignotement une fois traitement terminé + self._start_mode6_blinking() + # Activer aussi "Explorer le dossier de sortie" dès que les fichiers existent + if getattr(self, "_mode6_explore_output_btn", None): + try: + self._mode6_explore_output_btn.config(state="normal") + except Exception: + pass + + def _on_mode6_button_clicked(self) -> None: + # stop blinking and start flash (non-interactif) + if self._mode6_blinking: + self._stop_mode6_blinking() + + if self._mode6_btn: + self._mode6_btn.config( + state="disabled", text=self._get_ui_t()["mode6_btn_running"] + ) + + # determine chosen drive + chosen_index: int | None = None + try: + sel = list(self._mode6_drive_list.curselection()) + if sel: + chosen_index = sel[0] + except Exception: + chosen_index = None + + if ( + chosen_index is None + or chosen_index < 0 + or chosen_index >= len(self._mode6_drives) + ): + self._refresh_mode6_drives() + chosen_index = 0 if self._mode6_drives else None + + if chosen_index is None: + messagebox.showwarning("Attention", self._get_ui_t()["mode6_no_drives"]) + if self._mode6_btn: + self._mode6_btn.config(state="normal") + return + + letter, _label, _size = self._mode6_drives[chosen_index] + dst_drive = f"{letter}\\\\" + overwrite = bool(self._mode6_overwrite_var.get()) + + self._mode6_flash_thread = threading.Thread( + target=self._mode6_flash_worker, + args=(dst_drive, overwrite), + daemon=True, + ) + self._mode6_flash_thread.start() + + def _mode6_flash_worker(self, dst_drive: str, overwrite: bool) -> None: + try: + # non-interactive: use toolkit internal helpers + self.tkmod._robocopy(self.sd_dir, dst_drive, overwrite) # type: ignore[attr-defined] + except Exception as e: + print(f"[GUI] Mode6 flash error: {e}") + finally: + self.root.after(0, self._on_mode6_flash_done) + + def _on_mode6_flash_done(self) -> None: + ui = self._get_ui_t() + if self._mode6_btn: + self._mode6_btn.config(state="disabled", text=ui["mode6_done"]) + if getattr(self, "_mode6_explore_output_btn", None): + self._mode6_explore_output_btn.config(state="normal") + + # Optionally open output folder + out_dir = self.sd_dir + + if messagebox.askyesno(ui["open_output_prompt"], ui["open_output_prompt"]): + try: + os.startfile(str(out_dir)) # type: ignore[attr-defined] + except Exception: + pass + # no auto re-blink + + def _on_quit_app_clicked(self) -> None: + ui = self._get_ui_t() + # Remplace messagebox.askokcancel par une popup custom avec bouton "Explorer" + result_holder: dict[str, bool] = {"ok": False} + + lang = self.lang_var.get() + cancel_lbl = "Annuler" if lang != "en" else "Cancel" + cancel_lbl = "Cancelar" if lang == "es" else cancel_lbl + ok_lbl = "OK" + + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + lbl = tk.Label( + dlg, + text=ui["quit_app_warning"], + justify="left", + padx=14, + pady=12, + ) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + def on_cancel() -> None: + result_holder["ok"] = False + try: + dlg.destroy() + except Exception: + pass + + def on_ok() -> None: + result_holder["ok"] = True + try: + dlg.destroy() + except Exception: + pass + + explore_btn = tk.Button( + btns, + text=ui.get("mode6_explore_output_btn", "Explorer le dossier de sortie"), + width=28, + command=explore_output_dir, + ) + explore_btn.grid(row=0, column=0, padx=6) + + b_cancel = tk.Button(btns, text=cancel_lbl, width=14, command=on_cancel) + b_cancel.grid(row=0, column=1, padx=6) + + b_ok = tk.Button(btns, text=ok_lbl, width=10, command=on_ok) + b_ok.grid(row=0, column=2, padx=6) + + self._center_toplevel(dlg) + # Centre la popup + try: + self.root.update_idletasks() + dlg.update_idletasks() + w = dlg.winfo_width() + h = dlg.winfo_height() + if w > 1 and h > 1: + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + dlg.geometry(f"+{x}+{y}") + except Exception: + pass + + self.root.wait_window(dlg) + + if not result_holder["ok"]: + return + + # Always stop blinking immediately (UI-only) + self._stop_mode6_blinking() + + # If processing is running, show a custom dialog (Annuler / Explorer / OK) + worker_alive = bool(self._worker and self._worker.is_alive()) + flash_alive = bool( + self._mode6_flash_thread and self._mode6_flash_thread.is_alive() + ) + + if worker_alive or flash_alive: + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + msg = ( + ui["quit_app_stopped_worker"] + if worker_alive + else ui["quit_app_warning"] + ) + lbl = tk.Label(dlg, text=msg, justify="left", padx=14, pady=12) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + lang = self.lang_var.get() + cancel_lbl = "Annuler" if lang != "en" else "Cancel" + cancel_lbl = "Cancelar" if lang == "es" else cancel_lbl + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + def on_cancel() -> None: + try: + dlg.destroy() + except Exception: + pass + + def on_ok() -> None: + # Request stop only on OK + if worker_alive: + try: + self.tkmod.PAUSE.request_stop() + except Exception: + pass + try: + dlg.destroy() + except Exception: + pass + self.root.after(300, self._wait_for_threads_then_exit) + + b_cancel = tk.Button(btns, text=cancel_lbl, width=14, command=on_cancel) + b_cancel.grid(row=0, column=1, padx=6) + + ok_btn = tk.Button(btns, text="OK", width=10, command=on_ok) + ok_btn.grid(row=0, column=2, padx=6) + + self._center_toplevel(dlg) + # Centre la popup + try: + self.root.update_idletasks() + dlg.update_idletasks() + w = dlg.winfo_width() + h = dlg.winfo_height() + if w > 1 and h > 1: + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + dlg.geometry(f"+{x}+{y}") + except Exception: + pass + + self.root.wait_window(dlg) + return + + # No running work -> cleanup now + self._cleanup_sd_dir() + + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + self._center_toplevel(dlg) + + lbl = tk.Label( + dlg, + text=ui["quit_app_cleanup_done"], + justify="left", + padx=14, + pady=12, + ) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + def on_ok() -> None: + try: + dlg.destroy() + except Exception: + pass + try: + self.root.destroy() + except Exception: + pass + + def on_close() -> None: + on_ok() + + dlg.protocol("WM_DELETE_WINDOW", on_close) + + ok_btn = tk.Button(btns, text="OK", width=10, command=on_ok) + ok_btn.grid(row=0, column=1, padx=6) + + def _wait_for_threads_then_exit(self) -> None: + worker_alive = bool(self._worker and self._worker.is_alive()) + flash_alive = bool( + self._mode6_flash_thread and self._mode6_flash_thread.is_alive() + ) + + if worker_alive or flash_alive: + self.root.after(300, self._wait_for_threads_then_exit) + return + + self._cleanup_sd_dir() + try: + self.root.destroy() + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # close + # ────────────────────────────────────────────────────────────────────────── + def _on_close_attempt(self) -> None: + ui = self._get_ui_t() + lang = self.lang_var.get() + cancel_lbl = "Annuler" if lang != "en" else "Cancel" + cancel_lbl = "Cancelar" if lang == "es" else cancel_lbl + ok_lbl = "OK" + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + # Dialog modale (au lieu de messagebox.askokcancel) pour ajouter le bouton "explorer" + if self._worker and self._worker.is_alive(): + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + msg = ( + "Traitement en cours.\n\n" + "Clique « Annuler » pour garder la fenêtre ouverte.\n" + "Clique « OK » pour demander l’arrêt du script et fermer." + ) + + lbl = tk.Label(dlg, text=msg, justify="left", padx=14, pady=12) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + def on_cancel() -> None: + try: + dlg.destroy() + except Exception: + pass + + def on_explore() -> None: + explore_output_dir() + + def on_ok() -> None: + try: + self.tkmod.PAUSE.request_stop() + except Exception: + pass + try: + dlg.destroy() + except Exception: + pass + try: + self.root.destroy() + except Exception: + pass + + b_cancel = tk.Button(btns, text=cancel_lbl, width=14, command=on_cancel) + b_cancel.grid(row=0, column=0, padx=6) + + b_explore = tk.Button( + btns, + text=ui.get("mode6_explore_output_btn", "Explorer dossier de sortie"), + width=24, + command=on_explore, + ) + b_explore.grid(row=0, column=1, padx=6) + + b_ok = tk.Button(btns, text=ok_lbl, width=10, command=on_ok) + b_ok.grid(row=0, column=2, padx=6) + + self._center_toplevel(dlg) + # Centre la popup + try: + self.root.update_idletasks() + dlg.update_idletasks() + w = dlg.winfo_width() + h = dlg.winfo_height() + if w > 1 and h > 1: + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + dlg.geometry(f"+{x}+{y}") + except Exception: + pass + + self.root.wait_window(dlg) + return + + self.root.destroy() + + def run(self) -> None: + self.root.mainloop() + + +def run_gui(toolkit_module, sd_dir: Path) -> None: + RetroBoxLEDGui(toolkit_module, sd_dir).run() + + +if __name__ == "__main__": + import RetroBoxLED_toolkit as toolkit # type: ignore + + sd = toolkit.get_sd_card_dir(Path(__file__).parent) + RetroBoxLEDGui(toolkit, sd).run() diff --git a/retroboxled_tools2/PROJET DMD ESP32.code-workspace b/retroboxled_tools2/PROJET DMD ESP32.code-workspace new file mode 100644 index 0000000..c90f615 --- /dev/null +++ b/retroboxled_tools2/PROJET DMD ESP32.code-workspace @@ -0,0 +1,30 @@ +{ + "folders": [ + { + "path": "D:/PROJET DMD ESP32" + }, + { + "path": ".." + }, + { + "path": "." + }, + { + "path": "../CROQUIS ARDUINO IDE" + }, + { + "path": "../firmware arduino IDE" + }, + { + "path": "../VCS envirronement" + } + ], + "settings": { + "terminal.integrated.accessibleViewFocusOnCommandExecution": true, + "terminal.integrated.accessibleViewPreserveCursorPosition": true, + "terminal.integrated.allowInUntrustedWorkspace": false, + "terminal.integrated.allowMnemonics": true, + "terminal.integrated.copyOnSelection": true, + "terminal.integrated.cursorBlinking": true + } +} \ No newline at end of file diff --git a/retroboxled_tools2/README.es.md b/retroboxled_tools2/README.es.md new file mode 100644 index 0000000..0b338b3 --- /dev/null +++ b/retroboxled_tools2/README.es.md @@ -0,0 +1,241 @@ +# 🎮 RetroBoxLED + +**Firmware ESP32** para **Recalbox** LED marquee (paneles HUB75/DMD 128x32 P4). + +✅ Compatible con Recalbox 10.1-PATREON-1-ALPHA-6.1 + +--- + +🌐 [English](README.md) | [Français](README.fr.md) | [Español](README.es.md) + +--- + +## ℹ️ Info + +Anteriormente desarrollé aplicaciones de gestión en C++, C# y VB .Net para proyectos personales y profesionales. +Debo admitir que la IA me ayudó mucho a construir esto en solo unos días. +Obviamente no es perfecto. Algunas listas grandes como ARCADE, MAME o FBNEO tardan mucho en mostrar una imagen. Por eso mantengo este proyecto abierto a todos, esperando que alguien lo mejore. + +## ✨ Características + +- **Reproducción de GIFs y PNGs** : Reproducción de GIFs y PNGs (`/Arcade`, `/BEST_OF_TOP_30`, `/Pixel_Art`, etc.) +- **Fallback** : `/systems/_defaults/_default.png` +- **Playlists** : `Arcade.txt`, `Favorites.txt`, `Consoles.txt` +- **MQTT** : Eventos de EmulationStation (`rungame`, `shutdown`, etc.) +- **Recalbox** : Modo Arcade automático + +## ⭐ Funcionamiento + +Por defecto, el ESP32 reproduce una playlist de GIFs. +En cuanto recibe información por MQTT, cambia automáticamente al modo ARCADE. +Cuando Recalbox se apaga, el ESP32 reanuda la reproducción de la playlist. +Si falta un GIF o PNG, utilizará el archivo de reemplazo ubicado en `/systems/_defaults`. + +## 📁 Estructura de la tarjeta SD + +La tarjeta SD debe estar formateada en FAT32 con la siguiente estructura. +Copie la carpeta `_defaults` en el directorio `systems` de su tarjeta SD. + +``` +RetroBoxLED SD Card/ +├── gifs/ +│ ├── Arcade/, BEST_OF_TOP_30/, Pixel_Art/ | GIFs +├── systems/ +│ ├── mame/, neogeo/, snes/ | Sistemas +│ │ ├── juego.gif o juego.png | Imagen de los juegos +│ ├── _defaults/ | Archivos de reemplazo +├── playlists/ +│ ├── Arcade.txt, Favorites.txt, Consoles.txt +``` + +## 🚀 Instalación + +Antes de usar, siga estos pasos en orden: + +1. **Configuración** : Configure el archivo `config.ini` +2. **Playlists** : Cree sus playlists +3. **Herramientas** : Use los scripts proporcionados +4. **Flash** : Flashee el firmware del ESP32 +5. **MQTT** : Comprenda el funcionamiento de MQTT +6. **Telnet** : Terminal Telnet para pruebas + +--- + +## 1 - ⚙️ Configuración + +El archivo `config.ini` debe colocarse en la raíz de la tarjeta SD. +Permite configurar los siguientes parámetros: + +```ini +# Info +info=0 # 0 = sin info al inicio, 1 = mostrar info al inicio + +# Playlist +playlist=TODO.txt # Lee la playlist indicada en /playlist +random=1 # 0 = reproducción en orden, 1 = reproducción aleatoria + +# Wi-Fi & Bluetooth +wifi_enabled=1 # 0 = Wi-Fi desactivado, 1 = Wi-Fi activado (dejar en 1) +wifi_ssid=mywifi # Nombre de su red Wi-Fi +wifi_password=mypassword # Contraseña Wi-Fi +bluetooth_enabled=0 # 0 = Bluetooth desactivado, 1 = Bluetooth activado (dejar en 0) + # Activar en caso de interferencias (ej: mando 8Bitdo Pro 3) +bluetooth_name=ESP32-GIF # Nombre Bluetooth + +wifi_static_enabled=1 # 0 = DHCP, 1 = IP estática (recomendado) +wifi_static_ip=192.168.20.240 # Solo si wifi_static_enabled=1 +wifi_gateway=192.168.20.1 # Solo si wifi_static_enabled=1 +wifi_subnet=255.255.255.0 # Solo si wifi_static_enabled=1 +wifi_dns1=1.1.1.1 # Solo si wifi_static_enabled=1 +wifi_dns2=8.8.8.8 # Solo si wifi_static_enabled=1 + +# MQTT +recalbox_ip=192.168.20.104 # Dirección IP fija de su Recalbox +``` + +--- + +## 2 - ▶️ Playlists + +Puede crear sus propias playlists. +La herramienta **Generador de Playlists v1.0.1.bat** (modificada desde [RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED)) se encuentra en la carpeta **tools** de este repositorio. +Lista todas las carpetas presentes en el directorio **gifs**. +Si tiene carpetas como `Arcade`, `BEST_OF_TOP_30`, `Pixel_Art`, etc. con GIFs, puede elegir cuáles incluir en su playlist (ej: carpetas 1, 3 y 5). +Para incluir todo en una sola playlist, introduzca `TODO`. + +--- + +## 3 - 🛠️ Herramientas + +Hay un script disponible: + +**`RetroBoxLED_toolkit.py`** +- Extrae las imágenes de sus carpetas de medios +- Convierte las imágenes al formato 128x32 +- Crea la caché de sistemas y juegos +- Copia todo en la tarjeta SD + +La mejor opción es colocar este archivo en una carpeta dedicada para tener todo a mano. +Simplemente siga las instrucciones en pantalla y elija las opciones deseadas. + +La mejor opción para el panel es realizar un scrape completo con Recalbox usando el tipo de imagen **SELECT LOGO TYPE**, ideal para el panel LED, como se muestra en la captura de pantalla a continuación. +Según su elección en **SELECT LOGO TYPE**, deberá volver a hacer un scrape y poner todo de nuevo en la tarjeta SD. +Tengo preferencia por **CLEAR**. + +![Scrapping_Recalbox](medias/Scrapping_Recalbox.png) + +Una vez finalizado el script, encontrará una carpeta `sd_card`. Copie su contenido en su tarjeta SD o consérvela como copia de seguridad. + +Puede descargar los PNGs de los sistemas desde el script o usar los suyos. +También se incluye un sistema llamado **`_defaults`**. Si coloca un archivo `_default.gif` o `_default.png` allí, se usará por defecto cuando no se encuentre ninguna imagen de sistema, así como al inicio. +Por defecto, los GIFs tienen prioridad sobre los PNGs. + +--- + +## 4 - ⚡ Flash + +Antes de comenzar, asegúrese de que su PC reconoce su ESP32. + +## 💡 ¿ESP32 no detectado? + +**Si "Install" no encuentra el puerto COM**: + +| Chip USB | Controladores | +|----------|--------------| +| **CP2102** | [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | +| **CH340/CH341** | [SparkFun](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all) | + +### **[👉 Instalar desde la página web de RETRO PIXEL LED](https://jamyz.github.io/RetroBoxLED/)** + +Pasos de instalación: + +1. Use un navegador compatible (Google Chrome o Microsoft Edge). +2. Conecte su ESP32 al puerto USB de su ordenador. +3. Haga clic en el botón "Install" y seleccione el puerto COM correspondiente. +4. **Importante:** Marque la casilla "Erase device" para realizar un borrado completo de la memoria y evitar errores de fragmentación. + +--- + +## 5 - 🧠 MQTT — El cerebro de RetroBoxLED + +MQTT le indica al ESP32 qué debe mostrar. + +**Recalbox → "Lanzando MAME" → MQTT → ESP32 → "¡Mostrar el GIF o PNG de MAME!"** + +- **Sincronización** : El panel LED muestra exactamente el juego en curso +- **Red local** : 192.168.XXX.XXX (Wi-Fi arcade) + +Ejemplo: +``` +1. Lanza King of Fighters (mame/kof98) +2. El script marquee[rungame,...](permanent).sh detecta el evento → envía "mame/kof98" por MQTT +3. El ESP32 lo recibe → muestra /systems/mame/kof98.gif +4. ¿GIF no encontrado? → muestra /systems/_defaults/_default.gif +``` + +El archivo `marquee[rungame,endgame,systembrowsing,gamelistbrowsing,sleep,wakeup,stop,start](permanent).sh` +debe colocarse en `/recalbox/share/userscripts/` en su Recalbox. + +--- + +## 6 - >_ Telnet + +El firmware incluye un terminal Telnet para probar el ESP32. +Escriba `help` para mostrar la lista de comandos disponibles. +Puede enviar comandos para cambiar los GIFs mostrados, etc. +Esta función se eliminará más adelante, una vez que el código sea estable, para liberar espacio en el ESP32. + +--- + +## 📚 Bibliotecas requeridas + +Para compilar el proyecto desde el IDE de Arduino, instale las siguientes bibliotecas mediante el Gestor de bibliotecas o desde sus repositorios oficiales: + +- **[ESP32-HUB75-MatrixPanel-I2S-DMA](https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA)** : Control de alto rendimiento del panel LED mediante DMA. +- **[AnimatedGIF](https://github.com/bitbank2/AnimatedGIF)** : Decodificador eficiente para leer archivos GIF desde la tarjeta SD. +- **[pngle](https://github.com/kikuchan/pngle)** : Lectura de archivos PNG desde la tarjeta SD. +- **[WiFiManager](https://github.com/tzapu/WiFiManager)** : Gestión de la conexión Wi-Fi mediante un portal cautivo. +- **[Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library)** : Biblioteca base para mostrar texto y formas geométricas. +- **[ArduinoJson](https://github.com/bblanchon/ArduinoJson)** : Gestión de archivos de configuración y comunicación web. + +--- + +## 🛒 Lista de materiales + +Para garantizar la compatibilidad, se recomienda usar los componentes probados durante el desarrollo: + +- **Microcontrolador** : [ESP32 DevKit V1 (38 pines)](https://es.aliexpress.com/item/1005005704190069.html) +- **Panel matricial LED (HUB75)** : [Panel RGB P2.5 / P4](https://es.aliexpress.com/item/1005007439017560.html) +- **Lector de tarjeta** : [Módulo adaptador Micro SD (SPI)](https://es.aliexpress.com/item/1005005591145849.html) +- **Placa de conexión ESP32-Panel** : [DMDos Board V3 - Mortaca](https://www.mortaca.com/) *(Opcional: sin soldadura + lector SD integrado)* +- **Alimentación** : Fuente 5V (mínimo 4A recomendado para paneles 64x32) + +--- + +## 🤝 Créditos + +- [RetroPixelLED original](https://github.com/fjgordillo86/RetroPixelLED) +- [Comunidad Recalbox](https://www.recalbox.com/fr/) +- [Logos de sistemas publicados bajo licencia Creative Commons CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/) +- [Bounitos](https://github.com/BenoitBounar) +- 🎮 [Discord Jamyz](https://discord.com/users/.jamyz) + +--- + +## ☠️ Caídos en combate + +- Una vieja tarjeta SD de 1 GB usada para las pruebas +- 1 ESP32 +- 1 panel LED 64x32 + +--- + +## ☕ Apoyar el proyecto + +Si este proyecto te ha ayudado, puedes invitarme a un café: + +👉 [☕ Donar por PayPal](https://www.paypal.com/paypalme/jamyz77) + +--- + +**RetroBoxLED** = Recalbox + ¡perfección en píxeles LED! 😎 diff --git a/retroboxled_tools2/README.fr.md b/retroboxled_tools2/README.fr.md new file mode 100644 index 0000000..43b34ed --- /dev/null +++ b/retroboxled_tools2/README.fr.md @@ -0,0 +1,241 @@ +# 🎮 RetroBoxLED + +**Firmware ESP32** pour **Recalbox** LED marquee (panneaux HUB75/DMD 128x32 P4). + +✅ Compatible avec Recalbox 10.1-PATREON-1-ALPHA-6.1 + +--- + +🌐 [English](README.md) | [Français](README.fr.md) | [Español](README.es.md) + +--- + +## ℹ️ Info + +J'ai développé par le passé des applications de gestion en C++, C# et VB .Net pour des projets personnels et professionnels. +Mais je dois admettre que l'IA m'a beaucoup aidé à construire ceci en seulement quelques jours. +Ce n'est évidemment pas parfait. Certaines grandes listes comme ARCADE, MAME ou FBNEO mettent longtemps à afficher une image. C'est pourquoi je garde ce projet ouvert à tous, en attendant que quelqu'un fasse mieux. + +## ✨ Fonctionnalités + +- **Lecture de GIFs et PNGs** : Lecture de GIFs et de PNGs (`/Arcade`, `/BEST_OF_TOP_30`, `/Pixel_Art`, etc.) +- **Fallback** : `/systems/_defaults/_default.png` +- **Playlists** : `Arcade.txt`, `Favorites.txt`, `Consoles.txt` +- **MQTT** : Événements EmulationStation (`rungame`, `shutdown`, etc.) +- **Recalbox** : Mode Arcade automatique + +## ⭐ Fonctionnement + +Par défaut, l'ESP32 lit une playlist de GIFs. +Dès qu'il reçoit des informations via MQTT, il bascule automatiquement en mode ARCADE. +Lorsque Recalbox est éteint, l'ESP32 reprend la lecture de la playlist. +Si un GIF ou PNG est manquant, il utilisera le fichier de remplacement placé dans `/systems/_defaults`. + +## 📁 Structure de la carte SD + +La carte SD doit être formatée en FAT32 avec la structure suivante. +Copiez le dossier `_defaults` dans le répertoire `systems` de votre carte SD. + +``` +RetroBoxLED SD Card/ +├── gifs/ +│ ├── Arcade/, BEST_OF_TOP_30/, Pixel_Art/ | GIFs +├── systems/ +│ ├── mame/, neogeo/, snes/ | Systèmes +│ │ ├── jeux.gif ou jeux.png | Image des jeux +│ ├── _defaults/ | Fichiers de remplacement +├── playlists/ +│ ├── Arcade.txt, Favorites.txt, Consoles.txt +``` + +## 🚀 Installation + +Avant utilisation, suivez ces étapes dans l'ordre : + +1. **Configuration** : Paramétrez le fichier `config.ini` +2. **Playlists** : Créez vos playlists +3. **Outils** : Utilisez les scripts fournis +4. **Flash** : Flashez le firmware de l'ESP32 +5. **MQTT** : Comprenez le fonctionnement de MQTT +6. **Telnet** : Terminal Telnet pour les tests + +--- + +## 1 - ⚙️ Configuration + +Le fichier `config.ini` doit être placé à la racine de la carte SD. +Il permet de configurer les paramètres suivants : + +```ini +# Info +info=0 # 0 = pas d'info au démarrage, 1 = afficher les infos au démarrage + +# Playlist +playlist=TODO.txt # Lit la playlist indiquée dans /playlist +random=1 # 0 = lecture dans l'ordre, 1 = lecture aléatoire + +# Wi-Fi & Bluetooth +wifi_enabled=1 # 0 = Wi-Fi désactivé, 1 = Wi-Fi activé (laisser à 1) +wifi_ssid=mywifi # Nom de votre réseau Wi-Fi +wifi_password=mypassword # Mot de passe Wi-Fi +bluetooth_enabled=0 # 0 = Bluetooth désactivé, 1 = Bluetooth activé (laisser à 0) + # Activer en cas d'interférences (ex : manette 8Bitdo Pro 3) +bluetooth_name=ESP32-GIF # Nom Bluetooth + +wifi_static_enabled=1 # 0 = DHCP, 1 = IP statique (recommandé) +wifi_static_ip=192.168.20.240 # Uniquement si wifi_static_enabled=1 +wifi_gateway=192.168.20.1 # Uniquement si wifi_static_enabled=1 +wifi_subnet=255.255.255.0 # Uniquement si wifi_static_enabled=1 +wifi_dns1=1.1.1.1 # Uniquement si wifi_static_enabled=1 +wifi_dns2=8.8.8.8 # Uniquement si wifi_static_enabled=1 + +# MQTT +recalbox_ip=192.168.20.104 # Adresse IP fixe de votre Recalbox +``` + +--- + +## 2 - ▶️ Playlists + +Vous pouvez créer vos propres playlists. +L'outil **Generador de Playlists v1.0.1.bat** (modifié depuis [RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED)) se trouve dans le dossier **tools** de ce dépôt. +Il liste tous les dossiers présents dans le répertoire **gifs**. +Si vous avez des dossiers comme `Arcade`, `BEST_OF_TOP_30`, `Pixel_Art`, etc. contenant des GIFs, vous pouvez choisir lesquels inclure dans votre playlist (ex : dossiers 1, 3 et 5). +Pour tout inclure dans une seule playlist, entrez `TODO`. + +--- + +## 3 - 🛠️ Outils + +Un script est disponible : + +**`RetroBoxLED_toolkit.py`** +- Extrait les images de vos dossiers médias +- Convertit les images au format 128x32 +- Crée le cache des systèmes et des jeux +- Copie tout sur la carte SD + +La meilleure approche est de placer ce fichier dans un dossier dédié pour tout avoir à portée de main. +Suivez simplement les instructions à l'écran et choisissez les options souhaitées. + +La meilleure option pour le panneau est d'effectuer un scrape complet avec Recalbox en utilisant le type d'image **SELECT LOGO TYPE**, idéal pour le panneau LED, comme illustré dans la capture d'écran ci-dessous. +Selon votre choix dans **SELECT LOGO TYPE**, vous devrez refaire un scrape et remettre tout sur la carte SD. +J'ai une préférence pour **CLEAR**. + +![Scrapping_Recalbox](medias/Scrapping_Recalbox.png) + +Une fois le script terminé, vous trouverez un dossier `sd_card`. Copiez son contenu sur votre carte SD ou conservez-le comme sauvegarde. + +Vous pouvez télécharger les PNGs des systèmes depuis le script ou utiliser les vôtres. +Un système nommé **`_defaults`** est également inclus. Si vous y placez un fichier `_default.gif` ou `_default.png`, il sera utilisé par défaut quand aucune image de système n'est trouvée, ainsi qu'au démarrage. +Par défaut, les GIFs ont la priorité sur les PNGs. + +--- + +## 4 - ⚡ Flash + +Avant de commencer, assurez-vous que votre PC reconnaît votre ESP32. + +## 💡 ESP32 non détecté ? + +**Si « Install » ne trouve pas le port COM** : + +| Puce USB | Pilotes | +|----------|---------| +| **CP2102** | [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | +| **CH340/CH341** | [SparkFun](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all) | + +### **[👉 Installer depuis la page web RETRO PIXEL LED](https://jamyz.github.io/RetroBoxLED/)** + +Étapes d'installation : + +1. Utilisez un navigateur compatible (Google Chrome ou Microsoft Edge). +2. Connectez votre ESP32 au port USB de votre ordinateur. +3. Cliquez sur le bouton « Install » et sélectionnez le port COM correspondant. +4. **Important :** Cochez la case « Erase device » pour effectuer un effacement complet de la mémoire et éviter les erreurs de fragmentation. + +--- + +## 5 - 🧠 MQTT — Le cerveau de RetroBoxLED + +MQTT indique à l'ESP32 ce qu'il doit afficher. + +**Recalbox → « Lancement de MAME » → MQTT → ESP32 → « Afficher le GIF ou PNG de MAME ! »** + +- **Synchronisation** : Le panneau LED affiche exactement le jeu en cours +- **Réseau local** : 192.168.XXX.XXX (Wi-Fi arcade) + +Exemple : +``` +1. Vous lancez King of Fighters (mame/kof98) +2. Le script marquee[rungame,...](permanent).sh détecte l'événement → envoie "mame/kof98" via MQTT +3. L'ESP32 le reçoit → affiche /systems/mame/kof98.gif +4. GIF introuvable ? → affiche /systems/_defaults/_default.gif +``` + +Le fichier `marquee[rungame,endgame,systembrowsing,gamelistbrowsing,sleep,wakeup,stop,start](permanent).sh` +doit être placé dans `/recalbox/share/userscripts/` sur votre Recalbox. + +--- + +## 6 - >_ Telnet + +Le firmware inclut un terminal Telnet pour tester l'ESP32. +Tapez `help` pour afficher la liste des commandes disponibles. +Vous pouvez envoyer des commandes pour changer les GIFs affichés, etc. +Cette fonctionnalité sera supprimée ultérieurement, une fois le code stable, afin de libérer de l'espace sur l'ESP32. + +--- + +## 📚 Bibliothèques requises + +Pour compiler le projet depuis l'IDE Arduino, installez les bibliothèques suivantes via le Gestionnaire de bibliothèques ou depuis leurs dépôts officiels : + +- **[ESP32-HUB75-MatrixPanel-I2S-DMA](https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA)** : Contrôle haute performance du panneau LED via DMA. +- **[AnimatedGIF](https://github.com/bitbank2/AnimatedGIF)** : Décodeur efficace pour lire les fichiers GIF depuis la carte SD. +- **[pngle](https://github.com/kikuchan/pngle)** : Lecture de fichiers PNG depuis la carte SD. +- **[WiFiManager](https://github.com/tzapu/WiFiManager)** : Gestion de la connexion Wi-Fi via un portail captif. +- **[Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library)** : Bibliothèque de base pour l'affichage de texte et de formes géométriques. +- **[ArduinoJson](https://github.com/bblanchon/ArduinoJson)** : Gestion des fichiers de configuration et communication web. + +--- + +## 🛒 Liste du matériel + +Pour garantir la compatibilité, il est recommandé d'utiliser les composants testés lors du développement : + +- **Microcontrôleur** : [ESP32 DevKit V1 (38 broches)](https://es.aliexpress.com/item/1005005704190069.html) +- **Panneau matriciel LED (HUB75)** : [Panneau RGB P2.5 / P4](https://es.aliexpress.com/item/1005007439017560.html) +- **Lecteur de carte** : [Module adaptateur Micro SD (SPI)](https://es.aliexpress.com/item/1005005591145849.html) +- **Carte de connexion ESP32-Panneau** : [DMDos Board V3 - Mortaca](https://www.mortaca.com/) *(Optionnel : sans soudure + lecteur SD intégré)* +- **Alimentation** : Source 5V (minimum 4A recommandé pour les panneaux 64x32) + +--- + +## 🤝 Crédits + +- [RetroPixelLED original](https://github.com/fjgordillo86/RetroPixelLED) +- [Communauté Recalbox](https://www.recalbox.com/fr/) +- [Logos systèmes publiés sous licence Creative Commons CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/) +- [Bounitos](https://github.com/BenoitBounar) +- 🎮 [Discord Jamyz](https://discord.com/users/.jamyz) + +--- + +## ☠️ Tombés au combat + +- Une vieille carte SD de 1 Go utilisée pour les tests +- 1 ESP32 +- 1 panneau LED 64x32 + +--- + +## ☕ Soutenir le projet + +Si ce projet vous a aidé, vous pouvez m'offrir un café : + +👉 [☕ Faire un don via PayPal](https://www.paypal.com/paypalme/jamyz77) + +--- + +**RetroBoxLED** = Recalbox + perfection en pixels LED ! 😎 diff --git a/retroboxled_tools2/README.md b/retroboxled_tools2/README.md new file mode 100644 index 0000000..5dfa817 --- /dev/null +++ b/retroboxled_tools2/README.md @@ -0,0 +1,241 @@ +# 🎮 RetroBoxLED + +**ESP32 Firmware** for **Recalbox** LED marquee (HUB75/DMD 128x32 P4 panels). + +✅ Compatible with Recalbox 10.1-PATREON-1-ALPHA-6.1 + +--- + +🌐 [English](README.md) | [Français](README.fr.md) | [Español](README.es.md) + +--- + +## ℹ️ Info + +I previously developed management applications in C++, C# and VB .Net for personal and professional projects. +I have to admit that AI helped me a lot in building this in just a few days. +It is obviously not perfect. Some large lists like ARCADE, MAME or FBNEO take a long time to display an image. That is why I keep this project open to everyone, waiting for someone to do better. + +## ✨ Features + +- **GIF & PNG Playback** : Playback of GIFs and PNGs (`/Arcade`, `/BEST_OF_TOP_30`, `/Pixel_Art`, etc.) +- **Fallback** : `/systems/_defaults/_default.png` +- **Playlists** : `Arcade.txt`, `Favorites.txt`, `Consoles.txt` +- **MQTT** : EmulationStation events (`rungame`, `shutdown`, etc.) +- **Recalbox** : Automatic Arcade mode + +## ⭐ How it works + +By default, the ESP32 plays a GIF playlist. +As soon as it receives information via MQTT, it automatically switches to ARCADE mode. +When Recalbox is turned off, the ESP32 resumes playlist playback. +If a GIF or PNG is missing, it will use the fallback file placed in `/systems/_defaults`. + +## 📁 SD Card Structure + +The SD card must be formatted in FAT32 with the following structure. +Copy the `_defaults` folder into the `systems` directory of your SD card. + +``` +RetroBoxLED SD Card/ +├── gifs/ +│ ├── Arcade/, BEST_OF_TOP_30/, Pixel_Art/ | GIFs +├── systems/ +│ ├── mame/, neogeo/, snes/ | Systems +│ │ ├── game.gif or game.png | Game images +│ ├── _defaults/ | Fallback files +├── playlists/ +│ ├── Arcade.txt, Favorites.txt, Consoles.txt +``` + +## 🚀 Installation + +Before use, follow these steps in order: + +1. **Configuration** : Set up the `config.ini` file +2. **Playlists** : Create your playlists +3. **Tools** : Use the provided scripts +4. **Flash** : Flash the ESP32 firmware +5. **MQTT** : Understand how MQTT works +6. **Telnet** : Telnet terminal for testing + +--- + +## 1 - ⚙️ Configuration + +The `config.ini` file must be placed at the root of the SD card. +It allows you to configure the following parameters: + +```ini +# Info +info=0 # 0 = no info at startup, 1 = display info at startup + +# Playlist +playlist=TODO.txt # Reads the playlist indicated in /playlist +random=1 # 0 = sequential playback, 1 = random playback + +# Wi-Fi & Bluetooth +wifi_enabled=1 # 0 = Wi-Fi disabled, 1 = Wi-Fi enabled (leave at 1) +wifi_ssid=mywifi # Your Wi-Fi network name +wifi_password=mypassword # Wi-Fi password +bluetooth_enabled=0 # 0 = Bluetooth disabled, 1 = Bluetooth enabled (leave at 0) + # Enable in case of interference (e.g. 8Bitdo Pro 3 controller) +bluetooth_name=ESP32-GIF # Bluetooth name + +wifi_static_enabled=1 # 0 = DHCP, 1 = static IP (recommended) +wifi_static_ip=192.168.20.240 # Only if wifi_static_enabled=1 +wifi_gateway=192.168.20.1 # Only if wifi_static_enabled=1 +wifi_subnet=255.255.255.0 # Only if wifi_static_enabled=1 +wifi_dns1=1.1.1.1 # Only if wifi_static_enabled=1 +wifi_dns2=8.8.8.8 # Only if wifi_static_enabled=1 + +# MQTT +recalbox_ip=192.168.20.104 # Fixed IP address of your Recalbox +``` + +--- + +## 2 - ▶️ Playlists + +You can create your own playlists. +The **Generador de Playlists v1.0.1.bat** tool (modified from [RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED)) is located in the **tools** folder of this repository. +It lists all folders present in the **gifs** directory. +If you have folders like `Arcade`, `BEST_OF_TOP_30`, `Pixel_Art`, etc. containing GIFs, you can choose which ones to include in your playlist (e.g. folders 1, 3 and 5). +To include everything in a single playlist, enter `TODO`. + +--- + +## 3 - 🛠️ Tools + +A script is available: + +**`RetroBoxLED_toolkit.py`** +- Extracts images from your media folders +- Converts images to 128x32 format +- Creates the system and game cache +- Copies everything to the SD card + +The best approach is to place this file in a dedicated folder to have everything at hand. +Simply follow the on-screen instructions and choose the desired options. + +The best option for the panel is to perform a full scrape with Recalbox using the **SELECT LOGO TYPE** image type, ideal for the LED panel, as shown in the screenshot below. +Depending on your choice in **SELECT LOGO TYPE**, you will need to redo a scrape and put everything back on the SD card. +I have a preference for **CLEAR**. + +![Scrapping_Recalbox](medias/Scrapping_Recalbox.png) + +Once the script is complete, you will find an `sd_card` folder. Copy its contents to your SD card or keep it as a backup. + +You can download system PNGs from the script or use your own. +A system named **`_defaults`** is also included. If you place a `_default.gif` or `_default.png` file there, it will be used by default when no system image is found, as well as at startup. +By default, GIFs take priority over PNGs. + +--- + +## 4 - ⚡ Flash + +Before starting, make sure your PC recognizes your ESP32. + +## 💡 ESP32 not detected? + +**If "Install" cannot find the COM port**: + +| USB Chip | Drivers | +|----------|---------| +| **CP2102** | [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) | +| **CH340/CH341** | [SparkFun](https://learn.sparkfun.com/tutorials/how-to-install-ch340-drivers/all) | + +### **[👉 Install from the RETRO PIXEL LED web page](https://jamyz.github.io/RetroBoxLED/)** + +Installation steps: + +1. Use a compatible browser (Google Chrome or Microsoft Edge). +2. Connect your ESP32 to your computer's USB port. +3. Click the "Install" button and select the corresponding COM port. +4. **Important:** Check the "Erase device" box to perform a full memory erase and avoid fragmentation errors. + +--- + +## 5 - 🧠 MQTT — The brain of RetroBoxLED + +MQTT tells the ESP32 what to display. + +**Recalbox → "Launching MAME" → MQTT → ESP32 → "Display the MAME GIF or PNG!"** + +- **Synchronization** : The LED panel displays exactly the game being played +- **Local network** : 192.168.XXX.XXX (arcade Wi-Fi) + +Example: +``` +1. You launch King of Fighters (mame/kof98) +2. The marquee[rungame,...](permanent).sh script detects the event → sends "mame/kof98" via MQTT +3. The ESP32 receives it → displays /systems/mame/kof98.gif +4. GIF not found? → displays /systems/_defaults/_default.gif +``` + +The file `marquee[rungame,endgame,systembrowsing,gamelistbrowsing,sleep,wakeup,stop,start](permanent).sh` +must be placed in `/recalbox/share/userscripts/` on your Recalbox. + +--- + +## 6 - >_ Telnet + +The firmware includes a Telnet terminal for testing the ESP32. +Type `help` to display the list of available commands. +You can send commands to change the GIFs displayed, etc. +This feature will be removed later, once the code is stable, in order to free up space on the ESP32. + +--- + +## 📚 Required Libraries + +To compile the project from the Arduino IDE, install the following libraries via the Library Manager or from their official repositories: + +- **[ESP32-HUB75-MatrixPanel-I2S-DMA](https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA)** : High-performance LED panel control via DMA. +- **[AnimatedGIF](https://github.com/bitbank2/AnimatedGIF)** : Efficient decoder for reading GIF files from the SD card. +- **[pngle](https://github.com/kikuchan/pngle)** : Reading PNG files from the SD card. +- **[WiFiManager](https://github.com/tzapu/WiFiManager)** : Wi-Fi connection management via a captive portal. +- **[Adafruit GFX Library](https://github.com/adafruit/Adafruit-GFX-Library)** : Base library for displaying text and geometric shapes. +- **[ArduinoJson](https://github.com/bblanchon/ArduinoJson)** : Configuration file management and web communication. + +--- + +## 🛒 Hardware List + +To ensure compatibility, it is recommended to use the components tested during development: + +- **Microcontroller** : [ESP32 DevKit V1 (38 pins)](https://es.aliexpress.com/item/1005005704190069.html) +- **LED Matrix Panel (HUB75)** : [RGB Panel P2.5 / P4](https://es.aliexpress.com/item/1005007439017560.html) +- **Card Reader** : [Micro SD Adapter Module (SPI)](https://es.aliexpress.com/item/1005005591145849.html) +- **ESP32-Panel Connection Board** : [DMDos Board V3 - Mortaca](https://www.mortaca.com/) *(Optional: solderless + integrated SD reader)* +- **Power Supply** : 5V source (minimum 4A recommended for 64x32 panels) + +--- + +## 🤝 Credits + +- [Original RetroPixelLED](https://github.com/fjgordillo86/RetroPixelLED) +- [Recalbox Community](https://www.recalbox.com/fr/) +- [System logos published under Creative Commons CC BY-NC-ND 4.0 license](https://creativecommons.org/licenses/by-nc-nd/4.0/) +- [Bounitos](https://github.com/BenoitBounar) +- 🎮 [Discord Jamyz](https://discord.com/users/.jamyz) + +--- + +## ☠️ Fallen in battle + +- An old 1 GB SD card used for testing +- 1 ESP32 +- 1 64x32 LED panel + +--- + +## ☕ Support the project + +If this project helped you, you can buy me a coffee: + +👉 [☕ Donate via PayPal](https://www.paypal.com/paypalme/jamyz77) + +--- + +**RetroBoxLED** = Recalbox + LED pixel perfection! 😎 diff --git a/retroboxled_tools2/RetroBoxLED_gui.py b/retroboxled_tools2/RetroBoxLED_gui.py new file mode 100644 index 0000000..d69cd81 --- /dev/null +++ b/retroboxled_tools2/RetroBoxLED_gui.py @@ -0,0 +1,2323 @@ +#!/usr/bin/env python3 +import queue +import sys +import threading +import subprocess +import os +import shutil +import re +import webbrowser +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Sequence, cast + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog, font as tkfont + + +@dataclass(frozen=True) +class GuiConfig: + mode_choice: str # "1".."5" + roms_root: Path + systems_selected: Optional[Sequence[Path]] + nas_user: str + nas_password: str + nas_path_is_unc: bool + + +UI_TRANSLATIONS = { + "fr": { + "sys_all_selected": "Aucun système sélectionné — tous les systèmes seront traités.", + "sys_sel_opt_all": "Tout sélectionner", + "sys_sel_opt_none": "Ne rien sélectionner", + "sys_sel_warn_empty": "Aucun système sélectionné. Cliquez sur « Tout sélectionner » ou sélectionnez au moins un système.", + "quit_app": "Quitter l'application", + "quit_app_warning_title": "Quitter et nettoyer", + "quit_app_warning": "Quitter l'application va supprimer les dossiers temporaires (sd_card). Continuer ?", + "quit_app_stopped_worker": "Traitement en cours : arrêt demandé avant la fermeture.", + "quit_app_cleanup_done": "Dossiers temporaires supprimés.", + "open_output_prompt": "Ouvrir le dossier de sortie contenant les éléments produits ?", + "open_output_yes": "Oui", + "open_output_no": "Non", + "mode6_panel_title": "Choix 6 — Copier sur la carte SD", + "mode6_btn_start": "Démarrer la copie", + "mode6_no_drives": "Aucun lecteur amovible détecté. Insérez votre carte SD puis réessayez.", + "mode6_drive_choice_none": "Aucun lecteur sélectionné — sélection du premier lecteur disponible.", + "mode6_overwrite_title": "Options", + "mode6_overwrite_yes": "Écraser les fichiers existants", + "mode6_overwrite_no": "Conserver les fichiers existants (ignorer)", + "mode6_btn_running": "Flash en cours…", + "mode6_done": "Copie sur la carte SD terminée.", + "progress_title": "Progression", + "btn_pause": "Pause", + "btn_resume": "Reprise", + "btn_skip": "Passe", + "btn_stop": "Stop", + "logs_details_title": "Détails / sortie", + "language_title": "Langue", + "mode_label": "Mode", + "roms_pick_btn": "Choisir dossier ROMs", + "start_btn": "Démarrer", + "detect_systems_btn": "Détecter systèmes (gamelist.xml)", + "systems_to_process_lbl": "Systèmes à traiter (clic pour sélectionner)", + "mode_detail_title": "Détail du mode", + "mode6_drives_title": "Lecteurs amovibles (carte SD)", + "mode6_explore_output_btn": "Explorer le dossier de sortie", + }, + "en": { + "sys_all_selected": "No system selected — all systems will be processed.", + "sys_sel_opt_all": "Select all", + "sys_sel_opt_none": "Select none", + "sys_sel_warn_empty": "No system selected. Click « Select all » or select at least one system.", + "quit_app": "Quit application", + "quit_app_warning_title": "Quit and cleanup", + "quit_app_warning": "Quitting will delete temporary folders (sd_card). Continue ?", + "quit_app_stopped_worker": "Processing is running: stop requested before closing.", + "quit_app_cleanup_done": "Temporary folders deleted.", + "open_output_prompt": "Open the output folder containing produced elements?", + "open_output_yes": "Yes", + "open_output_no": "No", + "mode6_panel_title": "Choice 6 — Copy to SD card", + "mode6_btn_start": "Start copy", + "mode6_no_drives": "No removable drive detected. Insert your SD card and try again.", + "mode6_drive_choice_none": "No drive selected — picking the first available drive.", + "mode6_overwrite_title": "Options", + "mode6_overwrite_yes": "Overwrite existing files", + "mode6_overwrite_no": "Keep existing files (skip)", + "mode6_btn_running": "Flashing…", + "mode6_done": "SD card copy completed.", + "progress_title": "Progress", + "btn_pause": "Pause", + "btn_resume": "Resume", + "btn_skip": "Skip", + "btn_stop": "Stop", + "logs_details_title": "Details / output", + "language_title": "Language", + "mode_label": "Mode", + "roms_pick_btn": "Choose ROMs folder", + "start_btn": "Start", + "detect_systems_btn": "Detect systems (gamelist.xml)", + "systems_to_process_lbl": "Systems to process (click to select)", + "mode_detail_title": "Mode details", + "mode6_drives_title": "Removable drives (SD card)", + "mode6_explore_output_btn": "Explore output folder", + }, + "es": { + "sys_all_selected": "No se seleccionó ningún sistema — se procesarán todos los sistemas.", + "sys_sel_opt_all": "Seleccionar todo", + "sys_sel_opt_none": "No seleccionar", + "sys_sel_warn_empty": "Ningún sistema seleccionado. Haz clic en « Seleccionar todo » o selecciona al menos un sistema.", + "quit_app": "Salir de la aplicación", + "quit_app_warning_title": "Salir y limpiar", + "quit_app_warning": "Al salir se borrarán las carpetas temporales (sd_card). ¿Continuar?", + "quit_app_stopped_worker": "Hay un proceso en ejecución: se solicitará la detención antes de cerrar.", + "quit_app_cleanup_done": "Carpetas temporales eliminadas.", + "open_output_prompt": "¿Abrir la carpeta de salida con los elementos generados?", + "open_output_yes": "Sí", + "open_output_no": "No", + "mode6_panel_title": "Opción 6 — Copiar a la tarjeta SD", + "mode6_btn_start": "Iniciar la copia", + "mode6_no_drives": "No se detectó ninguna unidad extraíble. Inserta tu tarjeta SD y vuelve a intentar.", + "mode6_drive_choice_none": "No se seleccionó ninguna unidad — se elige la primera disponible.", + "mode6_overwrite_title": "Opciones", + "mode6_overwrite_yes": "Sobrescribir archivos existentes", + "mode6_overwrite_no": "Conservar archivos existentes (omitir)", + "mode6_btn_running": "Flasheando…", + "mode6_done": "Copia en tarjeta SD completada.", + "progress_title": "Progreso", + "btn_pause": "Pausar", + "btn_resume": "Reanudar", + "btn_skip": "Saltar", + "btn_stop": "Parar", + "logs_details_title": "Detalles / salida", + "language_title": "Idioma", + "mode_label": "Modo", + "roms_pick_btn": "Elegir carpeta ROMs", + "start_btn": "Iniciar", + "detect_systems_btn": "Detectar sistemas (gamelist.xml)", + "systems_to_process_lbl": "Sistemas a procesar (clic para seleccionar)", + "mode_detail_title": "Detalles del modo", + "mode6_drives_title": "Unidades extraíbles (tarjeta SD)", + "mode6_explore_output_btn": "Explorar la carpeta de salida", + }, +} + + +class QueueWriter: + def __init__(self, q: "queue.Queue[str]"): + self._q = q + self._buf = "" + + def write(self, s: str) -> None: + if not s: + return + self._buf += s + while "\n" in self._buf: + line, self._buf = self._buf.split("\n", 1) + self._q.put(line + "\n") + + def flush(self) -> None: + if self._buf: + self._q.put(self._buf) + self._buf = "" + + +def _unc_root(unc_path: str) -> str: + p = unc_path.strip() + if not p.startswith("\\\\"): + return p + parts = p.split("\\") + if len(parts) < 4: + return p + return "\\\\" + parts[2] + "\\" + parts[3] + + +def _net_use_connect(unc_root: str, username: str, password: str) -> None: + cmd = ["net", "use", unc_root, f"/user:{username}", password, "/persistent:no"] + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _net_use_disconnect(unc_root: str) -> None: + cmd = ["net", "use", unc_root, "/delete", "/y"] + subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +class RetroBoxLEDGui: + def __init__(self, toolkit_module, sd_dir: Path): + self.tkmod = toolkit_module + self.sd_dir = sd_dir + + self.root = tk.Tk() + self.root.title("RetroBoxLED Toolkit - GUI") + self.root.configure(bg="#F3F3F3") + + self.style = ttk.Style(self.root) + try: + self.style.theme_use("clam") + except Exception: + pass + + self._log_q: "queue.Queue[str]" = queue.Queue() + self._worker: Optional[threading.Thread] = None + self._sys_list_adjusting = False + self._last_real_system_indices: set[int] = set() + self._last_sys_click_index: Optional[int] = None + # index réel le plus proche cliqué dans la Listbox (0..n-1), y compris index système >=3 + self._last_sys_clicked_index_any: Optional[int] = None + + self.lang_var = tk.StringVar(value="fr") + + # ── Mode 6 (flash) : clignotement + UI lecteurs (sans saisie clavier) + self._mode6_blinking = False + self._mode6_blink_job = None + self._mode6_drives = [] + self._mode6_selected_drive = None + self._mode6_overwrite = True + self._mode6_ui_frame = None + self._mode6_drive_list = None + self._mode6_btn = None + self._mode6_flash_thread: Optional[threading.Thread] = None + + self._build_top_tabs() + self._build_mode_area(self.tab_main) + self._build_progress_frame(self.tab_main) + + self._poll_logs() + + # Sérigraphie (bas à droite) + self.silk_label = tk.Label( + self.root, + text="GUI - Shan_ayA 2026", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9, "bold"), + ) + self.silk_label.place(relx=1.0, rely=1.0, anchor="se", x=-102, y=-10) + + self.root.protocol("WM_DELETE_WINDOW", self._on_close_attempt) + + # ────────────────────────────────────────────────────────────────────────── + # TOP TABS (compact logs + parameters) + # ────────────────────────────────────────────────────────────────────────── + def _build_top_tabs(self) -> None: + self.nb_top = ttk.Notebook(self.root) + self.nb_top.pack(fill="both", expand=True, padx=10, pady=8) + + self.tab_main = tk.Frame(self.nb_top, bg="#F3F3F3") + self.tab_logs = tk.Frame(self.nb_top, bg="#F3F3F3") + self.tab_params = tk.Frame(self.nb_top, bg="#F3F3F3") + self.tab_help = tk.Frame(self.nb_top, bg="#F3F3F3") + + self.nb_top.add(self.tab_main, text="Main") + self.nb_top.add(self.tab_logs, text="Logs") + self.nb_top.add(self.tab_params, text="Paramètres") + self.nb_top.add(self.tab_help, text="AIDE") + + self._build_logs_tab(self.tab_logs) + self._build_params_tab(self.tab_params) + self._build_help_tab(self.tab_help) + + def _build_logs_tab(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + + controls = tk.Frame(parent, bg="#F3F3F3", bd=0) + controls.pack(fill="x", padx=10, pady=(10, 6)) + + self.btn_pause = tk.Button( + controls, + text=ui["btn_pause"], + command=self._on_pause_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_pause.grid(row=0, column=0, padx=6, pady=4, sticky="w") + + self.btn_resume = tk.Button( + controls, + text=ui["btn_resume"], + command=self._on_resume_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_resume.grid(row=0, column=1, padx=6, pady=4, sticky="w") + + self.btn_skip = tk.Button( + controls, + text=ui["btn_skip"], + command=self._on_skip_clicked, + bg="#FF5C5C", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_skip.grid(row=0, column=2, padx=6, pady=4, sticky="w") + + self.btn_stop = tk.Button( + controls, + text=ui["btn_stop"], + command=self._on_stop_clicked, + bg="#B100FF", + fg="white", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_stop.grid(row=0, column=3, padx=6, pady=4, sticky="w") + + self.logs_details_title_lbl = tk.Label( + parent, + text=ui["logs_details_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self.logs_details_title_lbl.pack(anchor="w", padx=10, pady=(0, 6)) + + # Make logs area occupy most available space + text_frame = tk.Frame(parent, bg="#F3F3F3") + text_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + self.text = tk.Text( + text_frame, + height=1, + wrap="none", + bg="white", + fg="black", + borderwidth=3, + relief="solid", + ) + # expand + self.text.pack(side="left", fill="both", expand=True) + + scroll_y = tk.Scrollbar(text_frame, orient="vertical", command=self.text.yview) + scroll_y.pack(side="right", fill="y") + self.text.configure(yscrollcommand=scroll_y.set) + + scroll_x = tk.Scrollbar( + text_frame, orient="horizontal", command=self.text.xview + ) + scroll_x.pack(side="bottom", fill="x") + self.text.configure(xscrollcommand=scroll_x.set) + + def _build_params_tab(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + self.params_language_title_lbl = tk.Label( + parent, + text=ui["language_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.params_language_title_lbl.pack(anchor="w", padx=10, pady=(12, 6)) + + box = tk.Frame(parent, bg="#F3F3F3") + box.pack(anchor="w", padx=10, pady=6) + + langs = [("fr", "Français"), ("en", "English"), ("es", "Español")] + for i, (code, label) in enumerate(langs): + rb = tk.Radiobutton( + box, + text=label, + value=code, + variable=self.lang_var, + command=self._on_language_changed, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 10, "bold"), + ) + rb.grid(row=i, column=0, sticky="w", pady=2) + + def _build_help_tab(self, parent: tk.Frame) -> None: + self.tab_help_bg = "#F3F3F3" + container = tk.Frame(parent, bg=self.tab_help_bg) + container.pack(fill="both", expand=True, padx=10, pady=10) + + self.help_title_lbl = tk.Label( + container, + text="AIDE", + bg=self.tab_help_bg, + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.help_title_lbl.pack(anchor="w") + + text_frame = tk.Frame(container, bg=self.tab_help_bg) + text_frame.pack(fill="both", expand=True, pady=(8, 0)) + + scroll_y = tk.Scrollbar(text_frame, orient="vertical") + scroll_y.pack(side="right", fill="y") + + self.help_text = tk.Text( + text_frame, + wrap="word", + bg="white", + fg="black", + borderwidth=3, + relief="solid", + yscrollcommand=scroll_y.set, + ) + self.help_text.pack(side="left", fill="both", expand=True) + scroll_y.configure(command=self.help_text.yview) + + self._refresh_help_tab_content() + + def _refresh_help_tab_content(self) -> None: + if not getattr(self, "help_text", None): + return + + lang = ( + getattr(self, "lang_var", None).get() + if getattr(self, "lang_var", None) + else "fr" + ) + if lang == "en": + readme_name = "README.md" + elif lang == "es": + readme_name = "README.es.md" + else: + readme_name = "README.fr.md" + + if getattr(self, "help_title_lbl", None): + self.help_title_lbl.config(text=f"AIDE ({readme_name})") + + base_dir = Path(getattr(sys, "_MEIPASS", Path(__file__).parent)) + readme_path = base_dir / readme_name + try: + readme_text = readme_path.read_text(encoding="utf-8", errors="replace") + except Exception as e: + readme_text = f"Impossible de lire {readme_name} : {e}" + + # Reset + self.help_text.configure(state="normal") + self.help_text.delete("1.0", "end") + + # Tags + if not hasattr(self, "_help_url_tags"): + self._help_url_tags: dict[str, str] = {} + # tag "help_code" peut ne pas exister au premier affichage + try: + existing_font = self.help_text.tag_cget("help_code", "font") + if not existing_font: + self.help_text.tag_configure("help_code", font=("Courier New", 9)) + except tk.TclError: + self.help_text.tag_configure("help_code", font=("Courier New", 9)) + + link_re = re.compile(r"\[([^\]]+)\]\((https?://[^)]+)\)|(https?://[^\s\]]+)") + + def url_tag(url: str) -> str: + if url in self._help_url_tags: + return self._help_url_tags[url] + tag = f"help_url_{len(self._help_url_tags)}" + self._help_url_tags[url] = tag + self.help_text.tag_configure(tag, foreground="#0000EE", underline=True) + self.help_text.tag_bind( + tag, + "", + lambda e, u=url: webbrowser.open_new_tab(u), + ) + return tag + + in_code = False + for raw_line in readme_text.splitlines(True): + if raw_line.strip().startswith("```"): + in_code = not in_code + self.help_text.insert("end", raw_line, "help_code" if in_code else None) + continue + + if in_code: + self.help_text.insert("end", raw_line, "help_code") + continue + + # Outside code blocks: render markdown links + bare URLs + idx = 0 + for m in link_re.finditer(raw_line): + start, end = m.span() + if start > idx: + self.help_text.insert("end", raw_line[idx:start]) + + label = "" + url = "" + if m.group(1) and m.group(2): + label = m.group(1) + url = m.group(2) + elif m.group(3): + url = m.group(3) + label = url + + # Small cleanup for opening (avoid trailing punctuation) + url_open = url.rstrip(".,);:!?") + tag = url_tag(url_open) + self.help_text.insert("end", label, tag) + idx = end + + if idx < len(raw_line): + self.help_text.insert("end", raw_line[idx:]) + + self.help_text.configure(state="disabled") + + # ────────────────────────────────────────────────────────────────────────── + # MODE AREA + # ────────────────────────────────────────────────────────────────────────── + def _build_mode_area(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + outer = tk.Frame(parent, bg="#F3F3F3", bd=2, relief="solid", padx=10, pady=10) + outer.pack(fill="x", padx=10, pady=(10, 8)) + + outer.grid_columnconfigure(0, weight=1) + outer.grid_columnconfigure(1, weight=1) + outer.grid_columnconfigure(2, weight=1) + + # Left column: mode + ROMs path + start + left = tk.Frame(outer, bg="#F3F3F3") + left.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) + + self.mode_title_lbl = tk.Label( + left, + text=ui["mode_label"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.mode_title_lbl.pack(anchor="w") + + self.mode_var = tk.StringVar(value="1") + self._mode_radios: dict[str, tk.Radiobutton] = {} + + self._detail_templates = { + "fr": { + "1": "Mode 1 : extraction + conversion 128x32 + build games_cache + téléchargement _defaults + generation systems_cache.dat.", + "2": "Mode 2 : extraction uniquement (images depuis gamelist.xml).", + "3": "Mode 3 : conversion uniquement (PNG → 128x32).", + "4": "Mode 4 : build games_cache uniquement.", + "5": "Mode 5 : génération systems_cache.dat.", + }, + "en": { + "1": "Mode 1: extraction + 128x32 conversion + build games_cache + download _defaults + generate systems_cache.dat.", + "2": "Mode 2: extraction only (images from gamelist.xml).", + "3": "Mode 3: conversion only (PNG → 128x32).", + "4": "Mode 4: build games_cache only.", + "5": "Mode 5: generate systems_cache.dat.", + }, + "es": { + "1": "Modo 1: extracción + conversión 128x32 + build games_cache + descargar _defaults + generate systems_cache.dat.", + "2": "Modo 2: extracción solo (imágenes desde gamelist.xml).", + "3": "Modo 3: conversión solo (PNG → 128x32).", + "4": "Modo 4: build games_cache solo.", + "5": "Modo 5: generate systems_cache.dat.", + }, + } + + # Titles from toolkit translations + modes = [ + ("1", self.tkmod.tr("mode1_title")), + ("2", self.tkmod.tr("mode2_title")), + ("3", self.tkmod.tr("mode3_title")), + ("4", self.tkmod.tr("mode4_title")), + ("5", self.tkmod.tr("sysc_title")), + ] + + for m, label in modes: + rb = tk.Radiobutton( + left, + text=label, + variable=self.mode_var, + value=m, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 10, "bold"), + wraplength=240, + justify="left", + command=self._on_mode_changed, + ) + rb.pack(anchor="w", pady=2) + self._mode_radios[m] = rb + + # ROMs folder + path_box = tk.Frame(left, bg="#F3F3F3", bd=2, relief="solid", padx=8, pady=8) + path_box.pack(fill="x", pady=(12, 0)) + + self.roms_path_var = tk.StringVar(value="") + + self.btn_pick_roms = tk.Button( + path_box, + text=ui["roms_pick_btn"], + command=self._pick_roms_directory, + bg="#FFFFFF", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=6, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_pick_roms.pack(fill="x") + + tk.Label( + path_box, + textvariable=self.roms_path_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9), + wraplength=260, + justify="left", + ).pack(anchor="w", pady=(8, 0)) + + self.btn_start = tk.Button( + left, + text=ui["start_btn"], + command=self._on_start_clicked, + bg="#00D084", + fg="black", + bd=2, + relief="solid", + padx=12, + pady=8, + font=("TkDefaultFont", 12, "bold"), + ) + self.btn_start.pack(fill="x", pady=(12, 0)) + + self.btn_quit_app = tk.Button( + left, + text=( + self.tkmod.tr("main_opt_quit") if hasattr(self.tkmod, "tr") else "Quit" + ), + command=self._on_quit_app_clicked, + bg="#FF5C5C", + fg="black", + bd=2, + relief="solid", + padx=12, + pady=6, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_quit_app.pack(fill="x", pady=(8, 0)) + + # Middle column: systems detection (only for modes 1/2) + self.middle = tk.Frame(outer, bg="#F3F3F3", bd=0) + self.middle.grid(row=0, column=1, sticky="nsew", padx=10) + + self.btn_detect_systems = tk.Button( + self.middle, + text=ui["detect_systems_btn"], + command=self._on_detect_systems_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=8, + pady=6, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_detect_systems.pack(fill="x") + + self.systems_to_process_lbl = tk.Label( + self.middle, + text=ui["systems_to_process_lbl"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self.systems_to_process_lbl.pack(anchor="w", pady=(10, 6)) + + box = tk.Frame(self.middle, bg="#F3F3F3") + box.pack(fill="both", expand=True) + + self.sys_list = tk.Listbox( + box, + selectmode="multiple", + height=7, + width=28, + bg="white", + fg="black", + borderwidth=3, + relief="solid", + ) + self.sys_list.pack(side="left", fill="both", expand=True) + + scroll = tk.Scrollbar(box, orient="vertical", command=self.sys_list.yview) + scroll.pack(side="right", fill="y") + self.sys_list.configure(yscrollcommand=scroll.set) + self.sys_list.bind( + "<>", + self._on_systems_listbox_select_changed, + ) + self.sys_list.bind( + "", + self._on_sys_list_button1_clicked, + ) + + # Right column: mode details + self.right = tk.Frame(outer, bg="#F3F3F3") + self.right.grid(row=0, column=2, sticky="nsew", padx=(10, 0)) + + self.mode_detail_title_lbl = tk.Label( + self.right, + text=ui["mode_detail_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 12, "bold"), + ) + self.mode_detail_title_lbl.pack(anchor="w", pady=(0, 6)) + + self.mode_desc_var = tk.StringVar(value="") + self.mode_desc_label = tk.Label( + self.right, + textvariable=self.mode_desc_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10), + wraplength=270, + justify="left", + ) + self.mode_desc_label.pack(anchor="w", fill="x") + + # ── Mode 6 panel (hidden until previous pipeline is finished) + self._mode6_ui_frame = tk.Frame( + self.right, bg="#F3F3F3", bd=2, relief="solid", padx=8, pady=8 + ) + self._mode6_ui_frame.pack(fill="x", pady=(12, 0)) + self._mode6_ui_frame.pack_forget() + + self._mode6_panel_title_lbl = tk.Label( + self._mode6_ui_frame, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_panel_title" + ], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 11, "bold"), + ) + self._mode6_panel_title_lbl.pack(anchor="w") + + self._mode6_drives_title_lbl = tk.Label( + self._mode6_ui_frame, + text=ui["mode6_drives_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9, "bold"), + ) + self._mode6_drives_title_lbl.pack(anchor="w", pady=(8, 4)) + + drives_box = tk.Frame(self._mode6_ui_frame, bg="#F3F3F3") + drives_box.pack(fill="x") + + self._mode6_drive_list = tk.Listbox( + drives_box, + selectmode="browse", + height=5, + width=26, + bg="white", + fg="black", + borderwidth=3, + relief="solid", + ) + self._mode6_drive_list.pack(side="left", fill="both", expand=True) + + scroll = tk.Scrollbar( + drives_box, orient="vertical", command=self._mode6_drive_list.yview + ) + scroll.pack(side="right", fill="y") + self._mode6_drive_list.configure(yscrollcommand=scroll.set) + + self._mode6_overwrite_var = tk.BooleanVar(value=True) + self._mode6_overwrite_title_lbl = tk.Label( + self._mode6_ui_frame, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_overwrite_title" + ], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self._mode6_overwrite_title_lbl.pack(anchor="w", pady=(10, 4)) + + opt_box = tk.Frame(self._mode6_ui_frame, bg="#F3F3F3") + opt_box.pack(anchor="w") + + self._mode6_rb_overwrite_yes = tk.Radiobutton( + opt_box, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_overwrite_yes" + ], + variable=self._mode6_overwrite_var, + value=True, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 9, "bold"), + anchor="w", + ) + self._mode6_rb_overwrite_yes.pack(anchor="w") + + self._mode6_rb_overwrite_no = tk.Radiobutton( + opt_box, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_overwrite_no" + ], + variable=self._mode6_overwrite_var, + value=False, + bg="#F3F3F3", + fg="black", + activebackground="#E7E7E7", + font=("TkDefaultFont", 9, "bold"), + anchor="w", + ) + self._mode6_rb_overwrite_no.pack(anchor="w") + + self._mode6_btn = tk.Button( + self._mode6_ui_frame, + text=UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"])[ + "mode6_btn_start" + ], + command=self._on_mode6_button_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=6, + font=("TkDefaultFont", 11, "bold"), + state="disabled", + ) + self._mode6_btn.pack(fill="x", pady=(10, 0)) + + # Bouton "Explorer le dossier de sortie" (activé une fois la copie terminée) + self._mode6_explore_output_btn = tk.Button( + self._mode6_ui_frame, + text=ui["mode6_explore_output_btn"], + command=lambda: os.startfile( + str(self.sd_dir) + ), # type: ignore[attr-defined] + bg="#FFFFFF", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=6, + font=("TkDefaultFont", 10, "bold"), + state="disabled", + ) + self._mode6_explore_output_btn.pack(fill="x", pady=(0, 6)) + + self._on_mode_changed() + + # ────────────────────────────────────────────────────────────────────────── + # language + mode logic + # ────────────────────────────────────────────────────────────────────────── + def _set_toolkit_language(self, lang: str) -> None: + # toolkit has TRANSLATIONS + global T + try: + self.tkmod.T = self.tkmod.TRANSLATIONS[lang] + except Exception: + return + + def _on_language_changed(self) -> None: + lang = self.lang_var.get() + if lang not in ("fr", "en", "es"): + return + self._set_toolkit_language(lang) + + ui = self._get_ui_t() + + # update titles for radios (toolkit) + self._mode_radios["1"].config(text=self.tkmod.tr("mode1_title")) + self._mode_radios["2"].config(text=self.tkmod.tr("mode2_title")) + self._mode_radios["3"].config(text=self.tkmod.tr("mode3_title")) + self._mode_radios["4"].config(text=self.tkmod.tr("mode4_title")) + self._mode_radios["5"].config(text=self.tkmod.tr("sysc_title")) + + # update main UI labels/buttons (ours) + if getattr(self, "params_language_title_lbl", None): + self.params_language_title_lbl.config(text=ui["language_title"]) + + if getattr(self, "mode_title_lbl", None): + self.mode_title_lbl.config(text=ui["mode_label"]) + + if getattr(self, "btn_pick_roms", None): + self.btn_pick_roms.config(text=ui["roms_pick_btn"]) + + if getattr(self, "btn_start", None): + self.btn_start.config(text=ui["start_btn"]) + + if getattr(self, "btn_detect_systems", None): + self.btn_detect_systems.config(text=ui["detect_systems_btn"]) + + if getattr(self, "systems_to_process_lbl", None): + self.systems_to_process_lbl.config(text=ui["systems_to_process_lbl"]) + + if getattr(self, "mode_detail_title_lbl", None): + self.mode_detail_title_lbl.config(text=ui["mode_detail_title"]) + + if getattr(self, "progress_title_lbl", None): + self.progress_title_lbl.config(text=ui["progress_title"]) + + # Boutons Logs + if getattr(self, "btn_pause", None): + self.btn_pause.config(text=ui["btn_pause"]) + if getattr(self, "btn_resume", None): + self.btn_resume.config(text=ui["btn_resume"]) + if getattr(self, "btn_skip", None): + self.btn_skip.config(text=ui["btn_skip"]) + if getattr(self, "btn_stop", None): + self.btn_stop.config(text=ui["btn_stop"]) + + # Boutons Progress (onglet Main) + if getattr(self, "btn_pause_progress", None): + self.btn_pause_progress.config(text=ui["btn_pause"]) + if getattr(self, "btn_resume_progress", None): + self.btn_resume_progress.config(text=ui["btn_resume"]) + if getattr(self, "btn_skip_progress", None): + self.btn_skip_progress.config(text=ui["btn_skip"]) + if getattr(self, "btn_stop_progress", None): + self.btn_stop_progress.config(text=ui["btn_stop"]) + + if getattr(self, "btn_quit_app", None): + # bouton "Quitter" : tkmod.tr() fournit le texte localisé + try: + self.btn_quit_app.config(text=self.tkmod.tr("main_opt_quit")) + except Exception: + pass + + if getattr(self, "logs_details_title_lbl", None): + self.logs_details_title_lbl.config(text=ui["logs_details_title"]) + + if getattr(self, "_mode6_drives_title_lbl", None): + self._mode6_drives_title_lbl.config(text=ui["mode6_drives_title"]) + + if getattr(self, "_mode6_explore_output_btn", None): + self._mode6_explore_output_btn.config(text=ui["mode6_explore_output_btn"]) + + # mode details / titre panneau + if getattr(self, "mode_detail_title_lbl", None): + self.mode_detail_title_lbl.config(text=ui["mode_detail_title"]) + + # update description + mode6 texts + self._update_mode_desc() + self._sync_mode6_texts() + + # refresh help tab (README language + links) + self._refresh_help_tab_content() + + def _update_mode_desc(self) -> None: + mode = self.mode_var.get() + lang = self.lang_var.get() + self.mode_desc_var.set( + self._detail_templates.get(lang, self._detail_templates["fr"]).get(mode, "") + ) + + def _on_mode_changed(self) -> None: + mode = self.mode_var.get() + + if mode in ("1", "2"): + self.middle.grid() + self.btn_detect_systems.config(state="normal") + # auto-detect if possible + self._maybe_autodetect_systems() + else: + self.middle.grid_remove() + + self._update_mode_desc() + + def _maybe_autodetect_systems(self) -> None: + path_str = self.roms_path_var.get().strip() + if not path_str: + return + roms_root = Path(path_str) + if not roms_root.exists(): + return + + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + is_unc = str(roms_root).startswith("\\\\") + + try: + systems = self._find_systems(roms_root) + except Exception: + if is_unc: + self._show_credentials_if_needed(True) + messagebox.showwarning( + "Attention", + "Accès réseau impossible (lecture gamelist.xml). Renseignez NAS user/mdp, puis cliquez « Détecter systèmes ».", + ) + return + raise + + self.sys_list.delete(0, "end") + + # 0="Tout sélectionner", 1="Ne rien sélectionner", 2="" separator + # Note: tk.Listbox ne supporte pas itemconfig(font=...), donc on ne force pas l'italique ici. + self.sys_list.insert("end", ui["sys_sel_opt_all"]) + self.sys_list.insert("end", ui["sys_sel_opt_none"]) + # Ne pas insérer "" : certains thèmes/implémentations peuvent rendre la hauteur peu fiable. + self.sys_list.insert("end", " ") + + for sys_path in systems: + self.sys_list.insert("end", sys_path.name) + + # Garantir l'affichage depuis le haut (sinon on peut “ne voir” qu'un seul item). + try: + self.sys_list.yview_moveto(0.0) + except Exception: + pass + + def _find_systems(self, roms_root: Path) -> list[Path]: + systems: list[Path] = [] + for d in roms_root.iterdir(): + if d.is_dir() and (d / "gamelist.xml").exists(): + systems.append(d) + systems.sort(key=lambda p: p.name.lower()) + return systems + + def _on_sys_list_button1_clicked(self, _event: object = None) -> None: + # capture l'intention (index cliqué: 0/1/2) et la sélection "réelle" (indices >= 3 : systèmes) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + try: + if _event is not None and hasattr(_event, "y"): + click_index = self.sys_list.nearest(getattr(_event, "y")) + if isinstance(click_index, int): + self._last_sys_clicked_index_any = click_index + if click_index in (0, 1, 2): + self._last_sys_click_index = click_index + except Exception: + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + + try: + selected = list(self.sys_list.curselection()) + self._last_real_system_indices = {i for i in selected if i >= 3} + except Exception: + self._last_real_system_indices = set() + + def _on_systems_listbox_select_changed(self, _event: object = None) -> None: + if self._sys_list_adjusting: + return + + total_items = self.sys_list.size() + # Pas de place pour sentinelles + séparateur + systèmes + if total_items <= 3: + return + + try: + self._sys_list_adjusting = True + + selected = list(self.sys_list.curselection()) + selected_set = set(selected) + + total_systems = total_items - 3 + systems_start = 3 + systems_end_exclusive = systems_start + total_systems + + # Si on a cliqué un système réel (index >= 3), on veut autoriser le mode manuel : + # - on retire uniquement les sentinelles + # - on ne force jamais "Tout/Ne rien" + if ( + self._last_sys_clicked_index_any is not None + and self._last_sys_clicked_index_any >= systems_start + ): + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + # Cas délicat : au clic, Tk peut laisser transitoirement 0 et 1 sélectionnés. + # On tranche alors selon l'index réellement cliqué (via _on_sys_list_button1_clicked). + if 0 in selected_set and 1 in selected_set: + intent = self._last_sys_click_index + if intent == 0: + # "Tout sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + for i in range(systems_start, systems_end_exclusive): + self.sys_list.selection_set(i) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + if intent == 1: + # "Ne rien sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(1) + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + # Sinon (intention None ou clic sur une ligne système) => mode manuel : + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + self._last_sys_clicked_index_any = None + return + + if 1 in selected_set: + # "Ne rien sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(1) + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(2) + self._last_sys_click_index = None + return + + if 0 in selected_set: + # "Tout sélectionner" + self.sys_list.selection_clear(0, "end") + self.sys_list.selection_set(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + for i in range(systems_start, systems_end_exclusive): + self.sys_list.selection_set(i) + self._last_sys_click_index = None + return + + # Sélection manuelle : on retire sentinelles 0/1 (et on s'assure que la ligne vide n'est pas sélectionnée) + self.sys_list.selection_clear(0) + self.sys_list.selection_clear(1) + self.sys_list.selection_clear(2) + + # Si l'utilisateur a sélectionné tous les systèmes individuellement => on met "Tout sélectionner" + real_selected = [i for i in selected if i >= systems_start] + if len(real_selected) == total_systems and total_systems > 0: + self.sys_list.selection_set(0) + + finally: + self._sys_list_adjusting = False + + # ────────────────────────────────────────────────────────────────────────── + # inputs + # ────────────────────────────────────────────────────────────────────────── + def _pick_roms_directory(self) -> None: + p = filedialog.askdirectory(title="Choisir dossier ROMs") + if not p: + return + self.roms_path_var.set(p) + + is_unc = str(p).startswith("\\\\") + + # On essaie de peupler la liste des systèmes directement. + # Si l'accès réseau échoue (gamelist.xml), on affichera user/mdp plus tard. + if self.mode_var.get() in ("1", "2"): + self._on_mode_changed() + + def _get_roms_root_or_warn(self) -> Optional[Path]: + path_str = self.roms_path_var.get().strip() + if not path_str: + messagebox.showwarning("Attention", "Choisis un dossier ROMs d'abord.") + return None + roms_root = Path(path_str) + if not roms_root.exists(): + messagebox.showwarning("Attention", "Dossier ROMs introuvable.") + return None + return roms_root + + def _on_detect_systems_clicked(self) -> None: + roms_root = self._get_roms_root_or_warn() + if roms_root is None: + return + + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + is_unc = str(roms_root).startswith("\\\\") + + try: + systems = self._find_systems(roms_root) + except Exception: + if is_unc: + self._show_credentials_if_needed(True) + messagebox.showwarning( + "Attention", + "Accès réseau impossible (lecture gamelist.xml). Renseignez NAS user/mdp, puis relancez « Détecter systèmes ».", + ) + return + raise + + self.sys_list.delete(0, "end") + + # 0="Tout sélectionner", 1="Ne rien sélectionner", 2="" separator + # Note: tk.Listbox ne supporte pas itemconfig(font=...), donc on ne force pas l'italique ici. + self.sys_list.insert("end", ui["sys_sel_opt_all"]) + self.sys_list.insert("end", ui["sys_sel_opt_none"]) + # Ne pas insérer "" : certains thèmes/implémentations peuvent rendre la hauteur peu fiable. + self.sys_list.insert("end", " ") + + for sys_path in systems: + self.sys_list.insert("end", sys_path.name) + + # Garantir l'affichage depuis le haut (sinon on peut “ne voir” qu'un seul item). + try: + self.sys_list.yview_moveto(0.0) + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # NAS credentials (only if UNC at start) + # ────────────────────────────────────────────────────────────────────────── + def _ensure_credentials_widgets(self) -> None: + if hasattr(self, "creds_frame"): + return + + # credentials panel under the mode details (right panel) + self.creds_frame = tk.Frame( + self.right, bg="#F3F3F3", bd=2, relief="solid", padx=8, pady=8 + ) + self.creds_frame.pack(fill="x", pady=(12, 0)) + + tk.Label( + self.creds_frame, + text="Identifiants NAS (UNC)", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 11, "bold"), + ).grid(row=0, column=0, columnspan=2, sticky="w") + + tk.Label( + self.creds_frame, + text="NAS user", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ).grid(row=1, column=0, sticky="w", pady=(8, 2)) + + self.ent_nas_user = tk.Entry(self.creds_frame, width=22) + self.ent_nas_user.grid(row=2, column=0, sticky="w", pady=2) + + tk.Label( + self.creds_frame, + text="NAS password", + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ).grid(row=1, column=1, sticky="w", pady=(8, 2), padx=(10, 0)) + + self.ent_nas_pass = tk.Entry(self.creds_frame, width=22, show="*") + self.ent_nas_pass.grid(row=2, column=1, sticky="w", pady=2) + + def _show_credentials_if_needed(self, is_unc: bool) -> None: + self._ensure_credentials_widgets() + if is_unc: + self.creds_frame.pack(fill="x", pady=(12, 0)) + else: + self.creds_frame.pack_forget() + + def _get_nas_credentials_or_warn(self) -> Optional[tuple[str, str]]: + self._ensure_credentials_widgets() + user = self.ent_nas_user.get().strip() + pwd = self.ent_nas_pass.get() + if not user or not pwd: + # Pas de messagebox au clic "Démarrer" : l'utilisateur doit saisir + # les identifiants dans le panneau NAS. + try: + if not user: + self.ent_nas_user.focus_set() + else: + self.ent_nas_pass.focus_set() + except Exception: + pass + return None + return user, pwd + + # ────────────────────────────────────────────────────────────────────────── + # progress + # ────────────────────────────────────────────────────────────────────────── + def _build_progress_frame(self, parent: tk.Frame) -> None: + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + + frm = tk.Frame(parent, bg="#F3F3F3", bd=2, relief="solid", padx=10, pady=10) + frm.pack(fill="x", padx=10, pady=(0, 10)) + # Fixe la largeur de la colonne texte (évite que les labels longs "poussent" la barre boutons) + frm.grid_columnconfigure(0, minsize=420, weight=0) + frm.grid_columnconfigure(1, weight=1) + + self.progress_title_lbl = tk.Label( + frm, + text=ui["progress_title"], + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 11, "bold"), + ) + self.progress_title_lbl.grid(row=0, column=0, sticky="w") + + self.progress_var = tk.StringVar(value="—") + self.progress_sub_var = tk.StringVar(value="") + self.progress_pct_var = tk.StringVar(value="0%") + + # Largeur FIXE (empêche Tk d'élargir la colonne si le texte est long) + tk.Label( + frm, + textvariable=self.progress_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + width=55, + wraplength=9999, + justify="left", + anchor="w", + ).grid(row=1, column=0, sticky="w", pady=4) + + tk.Label( + frm, + textvariable=self.progress_sub_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 9, "bold"), + width=55, + # Ne jamais wrapper : sinon la hauteur bouge et repousse la barre/boutons. + wraplength=9999, + justify="left", + anchor="w", + ).grid(row=2, column=0, sticky="w") + + self.progress = ttk.Progressbar( + frm, orient="horizontal", length=600, mode="determinate" + ) + self.progress.grid(row=0, column=1, rowspan=3, padx=10, sticky="we") + self.progress.configure(maximum=100) + + # Affiche un % centré sur la barre (ne déplace pas la mise en page) + self.progress_pct_label = tk.Label( + frm, + textvariable=self.progress_pct_var, + bg="#F3F3F3", + fg="black", + font=("TkDefaultFont", 10, "bold"), + ) + self.progress_pct_label.place( + in_=self.progress, relx=0.5, rely=0.5, anchor="center" + ) + + # Buttons under progression (main tab) + controls = tk.Frame(frm, bg="#F3F3F3") + controls.grid(row=3, column=0, columnspan=2, sticky="we", pady=(10, 0)) + + self.btn_pause_progress = tk.Button( + controls, + text=ui["btn_pause"], + command=self._on_pause_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_pause_progress.grid(row=0, column=0, padx=6, pady=4, sticky="w") + + self.btn_resume_progress = tk.Button( + controls, + text=ui["btn_resume"], + command=self._on_resume_clicked, + bg="#FFD400", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_resume_progress.grid(row=0, column=1, padx=6, pady=4, sticky="w") + + self.btn_skip_progress = tk.Button( + controls, + text=ui["btn_skip"], + command=self._on_skip_clicked, + bg="#FF5C5C", + fg="black", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_skip_progress.grid(row=0, column=2, padx=6, pady=4, sticky="w") + + self.btn_stop_progress = tk.Button( + controls, + text=ui["btn_stop"], + command=self._on_stop_clicked, + bg="#B100FF", + fg="white", + bd=2, + relief="solid", + padx=10, + pady=4, + font=("TkDefaultFont", 10, "bold"), + ) + self.btn_stop_progress.grid(row=0, column=3, padx=6, pady=4, sticky="w") + + def _progress_cb_ui(self, kind: str, idx: int, total: int, label: str = "") -> None: + total = max(total, 1) + pct = int((idx / total) * 100) + self.progress.configure(maximum=100, value=pct) + self.progress_pct_var.set(f"{pct}%") + + # Ligne 1/2 stables : + # - "extraction" : LINE 1 = système, LINE 2 vide + # - "extraction_imgs" : LINE 2 seulement (pas de modification de la ligne 1) + # - autres kinds : LINE 1 = étape, LINE 2 = label tronqué + if kind == "extraction": + # Ligne 1 : système (sans idx/total afin que l'affichage ne dérive pas) + sys_name = label or "" + max_sys_len = 38 + if len(sys_name) > max_sys_len: + sys_name = sys_name[: max_sys_len - 1] + "…" + + title = "Extraction" + if sys_name: + title = f"{title} — {sys_name}" + + self._last_progress_detail = title + self.progress_var.set(title) + + # Ligne 2 : vide, sera remplie par extraction_imgs + self.progress_sub_var.set("") + return + + if kind == "extraction_imgs": + # Ligne 2 uniquement : images copiées/total + fichier en cours (tronqué) + shown = label or "" + max_len = 55 + if len(shown) > max_len: + shown = shown[: max_len - 1] + "…" + self.progress_sub_var.set(shown) + return + + # Autres étapes + if kind == "conversion": + title = f"Conversion {idx}/{total}" + elif kind == "cache": + title = f"Cache {idx}/{total}" + elif kind == "download_defaults": + title = f"Download defaults {idx}/{total}" + elif kind == "systems_cache": + title = f"systems_cache {idx}/{total}" + else: + title = f"{kind} {idx}/{total}" + + shown = label or "" + max_len = 55 + if len(shown) > max_len: + shown = shown[: max_len - 1] + "…" + + self._last_progress_detail = title + self.progress_var.set(title) + self.progress_sub_var.set(shown) + return + + def _progress_cb(self, kind: str, idx: int, total: int, label: str = "") -> None: + self.root.after(0, self._progress_cb_ui, kind, idx, total, label) + + # ────────────────────────────────────────────────────────────────────────── + # logs polling + # ────────────────────────────────────────────────────────────────────────── + def _poll_logs(self) -> None: + try: + while True: + line = self._log_q.get_nowait() + self._append_log(line) + except queue.Empty: + pass + self.root.after(100, self._poll_logs) + + def _append_log(self, s: str) -> None: + self.text.insert("end", s) + self.text.see("end") + try: + if int(self.text.index("end-1c").split(".")[0]) > 400: + self.text.delete("1.0", "200.0") + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # pause/skip/stop + # ────────────────────────────────────────────────────────────────────────── + def _on_pause_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_pause, "pause") + + def _on_resume_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_resume, "resume") + + def _on_skip_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_skip, "skip") + + def _on_stop_clicked(self) -> None: + self._safe_invoke_pause(self.tkmod.PAUSE.request_stop, "stop") + + def _safe_invoke_pause(self, fn: Callable[[], None], kind: str) -> None: + try: + fn() + # Ne pas écraser la progression détaillée (elle vient des progress_cb du worker). + # On ne met un label générique que si aucun détail n'a jamais été affiché. + detail = getattr(self, "_last_progress_detail", None) + if not detail: + self.progress_var.set(f"Commande: {kind}") + except Exception as e: + messagebox.showerror("Erreur", str(e)) + + # ────────────────────────────────────────────────────────────────────────── + # worker + # ────────────────────────────────────────────────────────────────────────── + def _on_start_clicked(self) -> None: + if self._worker and self._worker.is_alive(): + messagebox.showwarning("Attention", "Traitement déjà en cours.") + return + + roms_root = self._get_roms_root_or_warn() + if roms_root is None: + return + + mode = self.mode_var.get() + is_unc = str(roms_root).startswith("\\\\") + + systems_selected: Optional[list[Path]] = None + nas_user = "" + nas_password = "" + connect_unc_for_worker = False + + # Pour modes 1/2 : on lit gamelist.xml pour construire la liste système. + # On ne demande NAS user/mdp que si la lecture échoue. + if mode in ("1", "2"): + try: + systems_all = self._find_systems(roms_root) + except Exception: + if not is_unc: + raise + # Accès UNC nécessaire + self._show_credentials_if_needed(True) + creds = self._get_nas_credentials_or_warn() + if creds is None: + return + nas_user, nas_password = creds + connect_unc_for_worker = True + + unc = str(roms_root) + net_root = _unc_root(unc) + try: + _net_use_connect(net_root, nas_user, nas_password) + systems_all = self._find_systems(roms_root) + finally: + _net_use_disconnect(net_root) + + if not systems_all: + messagebox.showwarning( + "Attention", + "Aucun système détecté (gamelist.xml introuvable).", + ) + return + + selected_indices = list(self.sys_list.curselection()) + + # Sentinel mapping: + # 0="Tout sélectionner" (italique) + # 1="Ne rien sélectionner" (italique) + # 2="" separator + # systèmes à partir de 3 + ui = UI_TRANSLATIONS.get(self.lang_var.get(), UI_TRANSLATIONS["fr"]) + + if 0 in selected_indices: + systems_selected = systems_all + else: + real_indices = [i - 3 for i in selected_indices if i >= 3] + systems_selected = [ + systems_all[i] for i in real_indices if 0 <= i < len(systems_all) + ] + + # Si aucun système réel n’est sélectionné => avertir + return (pas de traitement) + if not systems_selected: + messagebox.showwarning("Attention", ui["sys_sel_warn_empty"]) + try: + self.sys_list.focus_set() + except Exception: + pass + return + + # Pour les autres modes, on conserve le comportement précédent (mais sans forcer NAS user/mdp ici). + cfg = GuiConfig( + mode_choice=mode, + roms_root=roms_root, + systems_selected=cast(Optional[Sequence[Path]], systems_selected), + nas_user=nas_user, + nas_password=nas_password, + nas_path_is_unc=(is_unc and connect_unc_for_worker), + ) + + self.progress_var.set("Démarrage...") + self.progress.configure(maximum=100, value=0) + self.text.delete("1.0", "end") + + self._worker = threading.Thread( + target=self._worker_main, args=(cfg,), daemon=True + ) + self._worker.start() + + def _worker_main(self, cfg: GuiConfig) -> None: + toolkit = self.tkmod + net_root = None + + if cfg.nas_path_is_unc: + unc = str(cfg.roms_root) + net_root = _unc_root(unc) + try: + _net_use_connect(net_root, cfg.nas_user, cfg.nas_password) + print(f"[NAS] net use connect: {net_root}") + except Exception as e: + print(f"[NAS] net use connect failed: {e}") + net_root = None + + log_writer = QueueWriter(self._log_q) + old_stdout = sys.stdout + old_stderr = sys.stderr + try: + sys.stdout = log_writer # type: ignore[assignment] + sys.stderr = log_writer # type: ignore[assignment] + + try: + toolkit.ensure_dependencies() + except Exception: + pass + + toolkit.PAUSE.request_resume() + + mode = cfg.mode_choice + if mode == "1": + self._pipeline_mode_1(toolkit, cfg) + elif mode == "2": + self._pipeline_mode_2(toolkit, cfg) + elif mode == "3": + self._pipeline_mode_3(toolkit, cfg) + elif mode == "4": + self._pipeline_mode_4(toolkit, cfg) + elif mode == "5": + self._pipeline_mode_5(toolkit, cfg) + else: + print(f"Mode inconnu: {mode}") + + # Si l'utilisateur n'a pas demandé Stop, alors on "révèle" le choix 6 + # avec clignotement (via root.after car on est dans un thread worker). + if not toolkit.PAUSE.should_stop(): + self.root.after(0, self._on_pipeline_finished) + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + if net_root: + try: + _net_use_disconnect(net_root) + print(f"[NAS] net use disconnect: {net_root}") + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # pipelines + # ────────────────────────────────────────────────────────────────────────── + def _pipeline_mode_1(self, toolkit, cfg: GuiConfig) -> None: + sd_dir = self.sd_dir + sd_dir.mkdir(parents=True, exist_ok=True) + toolkit.prepare_sd_card(sd_dir, interactive=False) + + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + tag_configs = [("logo", "")] + selected_systems = cfg.systems_selected + + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = toolkit.run_extraction( + cfg.roms_root, + systems_out, + tag_configs, + log_file, + selected_systems=selected_systems, + progress_cb=self._progress_cb, + listen_keyboard=False, + ) + toolkit._write_log(log_file, cfg.roms_root, grand) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + toolkit.run_conversion( + systems_out, progress_cb=self._progress_cb, listen_keyboard=False + ) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + toolkit.build_cache(systems_out, sd_dir, progress_cb=self._progress_cb) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + toolkit.download_defaults( + sd_dir, + progress_cb=self._progress_cb, + listen_keyboard=False, + replace_existing=True, + download_missing=True, + ) + + if toolkit.PAUSE.should_stop(): + print("[GUI] Stop demandé.") + return + if toolkit.PAUSE.should_skip(): + toolkit.PAUSE.request_resume() + + sysc_out = sd_dir / "systems_cache.dat" + toolkit.build_systems_cache( + systems_out, sysc_out, progress_cb=self._progress_cb + ) + + print("[GUI] DONE mode 1") + + def _pipeline_mode_2(self, toolkit, cfg: GuiConfig) -> None: + sd_dir = self.sd_dir + sd_dir.mkdir(parents=True, exist_ok=True) + toolkit.prepare_sd_card(sd_dir, interactive=False) + + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + tag_configs = [("logo", "")] + selected_systems = cfg.systems_selected + + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = toolkit.run_extraction( + cfg.roms_root, + systems_out, + tag_configs, + log_file, + selected_systems=selected_systems, + progress_cb=self._progress_cb, + listen_keyboard=False, + ) + toolkit._write_log(log_file, cfg.roms_root, grand) + + print("[GUI] DONE mode 2") + + def _pipeline_mode_3(self, toolkit, cfg: GuiConfig) -> None: + systems_out = self.sd_dir / "systems" + if not systems_out.exists(): + print("[GUI] Mode 3: dossier systems/ introuvable (dans sd_card/systems).") + return + + toolkit.run_conversion( + systems_out, progress_cb=self._progress_cb, listen_keyboard=False + ) + print("[GUI] DONE mode 3") + + def _pipeline_mode_4(self, toolkit, cfg: GuiConfig) -> None: + systems_out = self.sd_dir / "systems" + if not systems_out.exists(): + print("[GUI] Mode 4: dossier systems/ introuvable (dans sd_card/systems).") + return + + toolkit.build_cache(systems_out, self.sd_dir, progress_cb=self._progress_cb) + print("[GUI] DONE mode 4") + + def _pipeline_mode_5(self, toolkit, cfg: GuiConfig) -> None: + systems_out = self.sd_dir / "systems" + sysc_out = self.sd_dir / "systems_cache.dat" + if not systems_out.exists(): + print("[GUI] Mode 5: dossier systems/ introuvable.") + return + + toolkit.build_systems_cache( + systems_out, sysc_out, progress_cb=self._progress_cb + ) + print("[GUI] DONE mode 5") + + # ────────────────────────────────────────────────────────────────────────── + # mode 6 + fin de traitement + actions finaux + # ────────────────────────────────────────────────────────────────────────── + def _get_ui_t(self) -> dict[str, str]: + lang = self.lang_var.get() + return UI_TRANSLATIONS.get(lang, UI_TRANSLATIONS["fr"]) + + def _cleanup_sd_dir(self) -> None: + try: + if self.sd_dir.exists(): + shutil.rmtree(self.sd_dir) + except Exception: + pass + + def _refresh_mode6_drives(self) -> None: + try: + drives = self.tkmod._list_removable_drives() # type: ignore[attr-defined] + except Exception: + drives = [] + self._mode6_drives = list(drives) + self._mode6_drive_list.delete(0, "end") + for i, (letter, label, size) in enumerate(self._mode6_drives): + self._mode6_drive_list.insert( + "end", f"{i+1} → {letter}\\\\ [{label}] {size}" + ) + if self._mode6_drives: + self._mode6_drive_list.selection_set(0) + else: + # keep empty + pass + + def _sync_mode6_texts(self) -> None: + ui = self._get_ui_t() + + if self._mode6_panel_title_lbl: + self._mode6_panel_title_lbl.config(text=ui["mode6_panel_title"]) + + if self._mode6_overwrite_title_lbl: + self._mode6_overwrite_title_lbl.config(text=ui["mode6_overwrite_title"]) + + if getattr(self, "_mode6_rb_overwrite_yes", None): + self._mode6_rb_overwrite_yes.config(text=ui["mode6_overwrite_yes"]) + + if getattr(self, "_mode6_rb_overwrite_no", None): + self._mode6_rb_overwrite_no.config(text=ui["mode6_overwrite_no"]) + + if self._mode6_btn: + self._mode6_btn.config(text=ui["mode6_btn_start"]) + + def _start_mode6_blinking(self) -> None: + if not self._mode6_ui_frame or not self._mode6_btn: + return + if not self._mode6_ui_frame.winfo_ismapped(): + self._mode6_ui_frame.pack(fill="x", pady=(12, 0)) + self._mode6_blinking = True + + self._refresh_mode6_drives() + self._mode6_btn.config(state="normal") + self._mode6_btn.config(text=self._get_ui_t()["mode6_btn_start"]) + + def _tick() -> None: + if not self._mode6_blinking: + return + # toggle bg for blink + current = self._mode6_btn.cget("bg") + new_bg = "#FFFFFF" if current != "#FFFFFF" else "#FFD400" + self._mode6_btn.config(bg=new_bg) + self._mode6_blink_job = self.root.after(400, _tick) + + self._mode6_blink_job = self.root.after(250, _tick) + + def _stop_mode6_blinking(self) -> None: + self._mode6_blinking = False + if self._mode6_blink_job is not None: + try: + self.root.after_cancel(self._mode6_blink_job) + except Exception: + pass + self._mode6_blink_job = None + if self._mode6_btn: + self._mode6_btn.config(bg="#FFD400") + + def _center_toplevel(self, win: tk.Toplevel) -> None: + # Centre la popup au milieu de la fenêtre principale + try: + self.root.update_idletasks() + win.update_idletasks() + + w = win.winfo_width() + h = win.winfo_height() + if w <= 1 or h <= 1: + return + + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + win.geometry(f"+{x}+{y}") + except Exception: + pass + + def _on_pipeline_finished(self) -> None: + # habilite mode 6 + clignotement une fois traitement terminé + self._start_mode6_blinking() + # Activer aussi "Explorer le dossier de sortie" dès que les fichiers existent + if getattr(self, "_mode6_explore_output_btn", None): + try: + self._mode6_explore_output_btn.config(state="normal") + except Exception: + pass + + def _on_mode6_button_clicked(self) -> None: + # stop blinking and start flash (non-interactif) + if self._mode6_blinking: + self._stop_mode6_blinking() + + if self._mode6_btn: + self._mode6_btn.config( + state="disabled", text=self._get_ui_t()["mode6_btn_running"] + ) + + # determine chosen drive + chosen_index: int | None = None + try: + sel = list(self._mode6_drive_list.curselection()) + if sel: + chosen_index = sel[0] + except Exception: + chosen_index = None + + if ( + chosen_index is None + or chosen_index < 0 + or chosen_index >= len(self._mode6_drives) + ): + self._refresh_mode6_drives() + chosen_index = 0 if self._mode6_drives else None + + if chosen_index is None: + messagebox.showwarning("Attention", self._get_ui_t()["mode6_no_drives"]) + if self._mode6_btn: + self._mode6_btn.config(state="normal") + return + + letter, _label, _size = self._mode6_drives[chosen_index] + dst_drive = f"{letter}\\\\" + overwrite = bool(self._mode6_overwrite_var.get()) + + self._mode6_flash_thread = threading.Thread( + target=self._mode6_flash_worker, + args=(dst_drive, overwrite), + daemon=True, + ) + self._mode6_flash_thread.start() + + def _mode6_flash_worker(self, dst_drive: str, overwrite: bool) -> None: + try: + # non-interactive: use toolkit internal helpers + self.tkmod._robocopy(self.sd_dir, dst_drive, overwrite) # type: ignore[attr-defined] + except Exception as e: + print(f"[GUI] Mode6 flash error: {e}") + finally: + self.root.after(0, self._on_mode6_flash_done) + + def _on_mode6_flash_done(self) -> None: + ui = self._get_ui_t() + if self._mode6_btn: + self._mode6_btn.config(state="disabled", text=ui["mode6_done"]) + if getattr(self, "_mode6_explore_output_btn", None): + self._mode6_explore_output_btn.config(state="normal") + + # Optionally open output folder + out_dir = self.sd_dir + + if messagebox.askyesno(ui["open_output_prompt"], ui["open_output_prompt"]): + try: + os.startfile(str(out_dir)) # type: ignore[attr-defined] + except Exception: + pass + # no auto re-blink + + def _on_quit_app_clicked(self) -> None: + ui = self._get_ui_t() + # Remplace messagebox.askokcancel par une popup custom avec bouton "Explorer" + result_holder: dict[str, bool] = {"ok": False} + + lang = self.lang_var.get() + cancel_lbl = "Annuler" if lang != "en" else "Cancel" + cancel_lbl = "Cancelar" if lang == "es" else cancel_lbl + ok_lbl = "OK" + + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + lbl = tk.Label( + dlg, + text=ui["quit_app_warning"], + justify="left", + padx=14, + pady=12, + ) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + def on_cancel() -> None: + result_holder["ok"] = False + try: + dlg.destroy() + except Exception: + pass + + def on_ok() -> None: + result_holder["ok"] = True + try: + dlg.destroy() + except Exception: + pass + + explore_btn = tk.Button( + btns, + text=ui.get("mode6_explore_output_btn", "Explorer le dossier de sortie"), + width=28, + command=explore_output_dir, + ) + explore_btn.grid(row=0, column=0, padx=6) + + b_cancel = tk.Button(btns, text=cancel_lbl, width=14, command=on_cancel) + b_cancel.grid(row=0, column=1, padx=6) + + b_ok = tk.Button(btns, text=ok_lbl, width=10, command=on_ok) + b_ok.grid(row=0, column=2, padx=6) + + self._center_toplevel(dlg) + # Centre la popup + try: + self.root.update_idletasks() + dlg.update_idletasks() + w = dlg.winfo_width() + h = dlg.winfo_height() + if w > 1 and h > 1: + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + dlg.geometry(f"+{x}+{y}") + except Exception: + pass + + self.root.wait_window(dlg) + + if not result_holder["ok"]: + return + + # Always stop blinking immediately (UI-only) + self._stop_mode6_blinking() + + # If processing is running, show a custom dialog (Annuler / Explorer / OK) + worker_alive = bool(self._worker and self._worker.is_alive()) + flash_alive = bool( + self._mode6_flash_thread and self._mode6_flash_thread.is_alive() + ) + + if worker_alive or flash_alive: + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + msg = ( + ui["quit_app_stopped_worker"] + if worker_alive + else ui["quit_app_warning"] + ) + lbl = tk.Label(dlg, text=msg, justify="left", padx=14, pady=12) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + lang = self.lang_var.get() + cancel_lbl = "Annuler" if lang != "en" else "Cancel" + cancel_lbl = "Cancelar" if lang == "es" else cancel_lbl + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + def on_cancel() -> None: + try: + dlg.destroy() + except Exception: + pass + + def on_ok() -> None: + # Request stop only on OK + if worker_alive: + try: + self.tkmod.PAUSE.request_stop() + except Exception: + pass + try: + dlg.destroy() + except Exception: + pass + self.root.after(300, self._wait_for_threads_then_exit) + + b_cancel = tk.Button(btns, text=cancel_lbl, width=14, command=on_cancel) + b_cancel.grid(row=0, column=1, padx=6) + + ok_btn = tk.Button(btns, text="OK", width=10, command=on_ok) + ok_btn.grid(row=0, column=2, padx=6) + + self._center_toplevel(dlg) + # Centre la popup + try: + self.root.update_idletasks() + dlg.update_idletasks() + w = dlg.winfo_width() + h = dlg.winfo_height() + if w > 1 and h > 1: + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + dlg.geometry(f"+{x}+{y}") + except Exception: + pass + + self.root.wait_window(dlg) + return + + # No running work -> cleanup now + self._cleanup_sd_dir() + + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + self._center_toplevel(dlg) + + lbl = tk.Label( + dlg, + text=ui["quit_app_cleanup_done"], + justify="left", + padx=14, + pady=12, + ) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + def on_ok() -> None: + try: + dlg.destroy() + except Exception: + pass + try: + self.root.destroy() + except Exception: + pass + + def on_close() -> None: + on_ok() + + dlg.protocol("WM_DELETE_WINDOW", on_close) + + ok_btn = tk.Button(btns, text="OK", width=10, command=on_ok) + ok_btn.grid(row=0, column=1, padx=6) + + def _wait_for_threads_then_exit(self) -> None: + worker_alive = bool(self._worker and self._worker.is_alive()) + flash_alive = bool( + self._mode6_flash_thread and self._mode6_flash_thread.is_alive() + ) + + if worker_alive or flash_alive: + self.root.after(300, self._wait_for_threads_then_exit) + return + + self._cleanup_sd_dir() + try: + self.root.destroy() + except Exception: + pass + + # ────────────────────────────────────────────────────────────────────────── + # close + # ────────────────────────────────────────────────────────────────────────── + def _on_close_attempt(self) -> None: + ui = self._get_ui_t() + lang = self.lang_var.get() + cancel_lbl = "Annuler" if lang != "en" else "Cancel" + cancel_lbl = "Cancelar" if lang == "es" else cancel_lbl + ok_lbl = "OK" + + def explore_output_dir() -> None: + try: + os.startfile(str(self.sd_dir)) # type: ignore[attr-defined] + except Exception: + pass + + # Dialog modale (au lieu de messagebox.askokcancel) pour ajouter le bouton "explorer" + if self._worker and self._worker.is_alive(): + dlg = tk.Toplevel(self.root) + dlg.title(ui["quit_app_warning_title"]) + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + msg = ( + "Traitement en cours.\n\n" + "Clique « Annuler » pour garder la fenêtre ouverte.\n" + "Clique « OK » pour demander l’arrêt du script et fermer." + ) + + lbl = tk.Label(dlg, text=msg, justify="left", padx=14, pady=12) + lbl.pack(fill="both", expand=True) + + btns = tk.Frame(dlg, padx=10, pady=10) + btns.pack() + + def on_cancel() -> None: + try: + dlg.destroy() + except Exception: + pass + + def on_explore() -> None: + explore_output_dir() + + def on_ok() -> None: + try: + self.tkmod.PAUSE.request_stop() + except Exception: + pass + try: + dlg.destroy() + except Exception: + pass + try: + self.root.destroy() + except Exception: + pass + + b_cancel = tk.Button(btns, text=cancel_lbl, width=14, command=on_cancel) + b_cancel.grid(row=0, column=0, padx=6) + + b_explore = tk.Button( + btns, + text=ui.get("mode6_explore_output_btn", "Explorer dossier de sortie"), + width=24, + command=on_explore, + ) + b_explore.grid(row=0, column=1, padx=6) + + b_ok = tk.Button(btns, text=ok_lbl, width=10, command=on_ok) + b_ok.grid(row=0, column=2, padx=6) + + self._center_toplevel(dlg) + # Centre la popup + try: + self.root.update_idletasks() + dlg.update_idletasks() + w = dlg.winfo_width() + h = dlg.winfo_height() + if w > 1 and h > 1: + root_x = self.root.winfo_rootx() + root_y = self.root.winfo_rooty() + root_w = self.root.winfo_width() + root_h = self.root.winfo_height() + x = root_x + (root_w - w) // 2 + y = root_y + (root_h - h) // 2 + dlg.geometry(f"+{x}+{y}") + except Exception: + pass + + self.root.wait_window(dlg) + return + + self.root.destroy() + + def run(self) -> None: + self.root.mainloop() + + +def run_gui(toolkit_module, sd_dir: Path) -> None: + RetroBoxLEDGui(toolkit_module, sd_dir).run() + + +if __name__ == "__main__": + import RetroBoxLED_toolkit as toolkit # type: ignore + + sd = toolkit.get_sd_card_dir(Path(__file__).parent) + RetroBoxLEDGui(toolkit, sd).run() diff --git a/retroboxled_tools2/RetroBoxLED_tool3-1.py b/retroboxled_tools2/RetroBoxLED_tool3-1.py new file mode 100644 index 0000000..0056212 --- /dev/null +++ b/retroboxled_tools2/RetroBoxLED_tool3-1.py @@ -0,0 +1,2266 @@ +#!/usr/bin/env python3 +""" +recalbox_toolkit.py — Unified tool for ESP32 Marquee +===================================================== +1. Gamelist extraction + 128x32 conversion + build cache +2. Gamelist extraction only +3. 128x32 conversion only +4. Build games_cache only +""" + +import os +import re +import sys +import shutil +import struct +import time +import threading +import tempfile +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path +from urllib.parse import unquote + +# ───────────────────────────────────────────────────────────────────────────── +# PAUSE CONTROLLER +# ───────────────────────────────────────────────────────────────────────────── + + +class PauseController: + """ + Écoute la touche P en arrière-plan pendant un traitement. + États : RUNNING / PAUSED / SKIP / STOP + """ + + RUNNING = "running" + PAUSED = "paused" + SKIP = "skip" + STOP = "stop" + + def __init__(self): + self.state = self.RUNNING + self._lock = threading.Lock() + self._thread = None + self._active = False + + def start(self, listen_keyboard: bool = True): + self.state = self.RUNNING + self._active = True + self._thread = None + if listen_keyboard: + self._thread = threading.Thread(target=self._listen, daemon=True) + self._thread.start() + + def stop(self): + self._active = False + + def request_pause(self): + with self._lock: + if self.state == self.RUNNING: + self.state = self.PAUSED + + def request_resume(self): + with self._lock: + if self.state in (self.PAUSED, self.SKIP): + self.state = self.RUNNING + + def request_skip(self): + with self._lock: + if self.state in (self.RUNNING, self.PAUSED): + self.state = self.SKIP + + def request_stop(self): + with self._lock: + if self.state in (self.RUNNING, self.PAUSED, self.SKIP): + self.state = self.STOP + + def _listen(self): + """Thread background : lit stdin ligne par ligne.""" + import sys + + while self._active: + try: + # On lit stdin sans bloquer le thread principal + # msvcrt sur Windows, sinon select sur Unix + if sys.platform == "win32": + import msvcrt + + if msvcrt.kbhit(): + ch = msvcrt.getwch() + if ord(ch) == 27: # ESC + self._on_pause() + time.sleep(0.1) + else: + import select + + if select.select([sys.stdin], [], [], 0.1)[0]: + ch = sys.stdin.read(1) + if ord(ch) == 27: # ESC + self._on_pause() + except Exception: + time.sleep(0.1) + + def _on_pause(self): + with self._lock: + if self.state != self.RUNNING: + return + self.state = self.PAUSED + + print(tr("pause_title")) + sep("─") + print(f" 1 → {tr('pause_opt1')}") + print(f" 2 → {tr('pause_opt2')}") + print(f" 3 → {tr('pause_opt3')}") + print() + + while True: + raw = input(tr("pause_choice")).strip() + if raw == "1": + print(tr("pause_resuming")) + with self._lock: + self.state = self.RUNNING + break + elif raw == "2": + print(tr("pause_skipping")) + with self._lock: + self.state = self.SKIP + break + elif raw == "3": + print(tr("pause_stopping")) + with self._lock: + self.state = self.STOP + break + else: + print(tr("pause_warn")) + + def is_running(self): + with self._lock: + return self.state == self.RUNNING + + def should_skip(self): + with self._lock: + return self.state == self.SKIP + + def should_stop(self): + with self._lock: + return self.state == self.STOP + + def wait_if_paused(self): + """Attend tant que l'état est PAUSED (bloque le thread principal).""" + while True: + with self._lock: + if self.state != self.PAUSED: + break + time.sleep(0.1) + + +# Global pause controller +PAUSE = PauseController() + +# ───────────────────────────────────────────────────────────────────────────── +# TRANSLATIONS +# ───────────────────────────────────────────────────────────────────────────── + +TRANSLATIONS = { + "fr": { + "pillow_installing": "⚙️ Pillow n'est pas installé. Installation automatique en cours...", + "pillow_ok": "✅ Pillow installé avec succès !\n", + "pillow_fail": "❌ Impossible d'installer Pillow.\n Lance manuellement : pip install Pillow\n La conversion 128x32 sera désactivée.\n", + "main_title": "RetroBoxLED Toolkit for Recalbox", + "dl_title": "🌐 DOSSIER _defaults (images systèmes)", + "dl_missing": " ℹ️ Aucun dossier _defaults/ trouvé dans sd_card/systems/.", + "dl_exists": " ℹ️ Le dossier _defaults/ existe déjà dans sd_card/systems/.", + "dl_ask_download": "Télécharger _defaults/ depuis GitHub (RetroBoxLED) ?", + "dl_ask_update": "Mettre à jour _defaults/ depuis GitHub (RetroBoxLED) ?", + "dl_skip": " ⏭️ Téléchargement ignoré.", + "dl_starting": "⬇️ Téléchargement des fichiers depuis GitHub...", + "dl_file_ok": lambda n, i, t: f" {i:4d}/{t} ✅ {n}", + "dl_file_err": lambda n, e: f" ⚠️ {n} — {e}", + "dl_done": lambda n: f"✅ {n} fichiers téléchargés dans _defaults/", + "dl_fail_api": "❌ API GitHub inaccessible. Vérifiez votre connexion internet.", + "dl_replacing": "🗑️ Remplacement du _defaults/ existant...", + "main_prompt": "Que voulez-vous faire ?", + "main_opt1": "Extraction gamelist + Conversion 128x32 + Build cache (TOUT)", + "main_opt2": "Seulement extraire les images des gamelists", + "main_opt3": "Seulement convertir des images en 128x32", + "main_opt4": "Seulement construire le games_cache.bin", + "main_opt7": "Interface graphique (Tkinter)", + "main_choice": "Votre choix (1-7) : ", + "main_opt_quit": "Quitter", + "main_warn": "⚠️ Tape un chiffre entre 0 et 7.\n", + "back": "↩ Retour en arrière", + "back_main": "\n ↩ Retour au menu principal...", + "back_roms": "\n ↩ Retour au choix du dossier roms...", + "yes_no": "(o/n)", + "yes_vals": ("o", "oui", "y", "yes"), + "no_vals": ("n", "non", "no"), + "warn_yn": "⚠️ Tape o ou n.\n", + "warn_choice": "⚠️ Tape 0, 1 ou 2.\n", + "after_menu": "Que voulez-vous faire ensuite ?", + "after_opt1": "Retour au menu principal", + "after_opt6": "Copier sur la carte SD maintenant (mode 6)", + "after_opt_files": "Copier les fichiers générés sur la carte SD", + "press_enter": "Appuie sur Entrée pour fermer...", + "press_enter_cont": "Appuie sur Entrée pour continuer quand même...", + "path_local": " 1 → Lecteur local (ex: D:\\Recalbox\\roms)", + "path_network": " 2 → Réseau / NAS (ex: \\\\192.168.1.1\\share\\roms)", + "path_choice": "Votre choix (0, 1 ou 2) : ", + "path_local_lbl": "Chemin du dossier (0 pour revenir) : ", + "path_net_lbl": "Chemin réseau (0 pour revenir) : ", + "path_not_found": "❌ Dossier introuvable. Vérifie le chemin et réessaie.\n", + "sd_erase_ask": "Voulez-vous l'effacer complètement avant de continuer ?", + "sd_erased": "🗑️ Dossier effacé et recréé.", + "sd_erase_err": "❌ Erreur lors de l'effacement. Ferme les fichiers ouverts dans sd_card/ et réessaie.", + "sd_kept": " ℹ️ Contenu conservé, les fichiers existants seront écrasés ou ignorés.", + "ext_title": "🚀 ÉTAPE 1/3 — EXTRACTION DES IMAGES", + "ext_roms_where": "\n📍 OÙ SE TROUVENT VOS ROMS ?", + "ext_systems": "systèmes avec gamelist.xml détectés", + "ext_games_found": "jeux trouvés", + "ext_xml_err": "ERREUR XML", + "ext_already": "déjà présente", + "ext_missing_tag": "MANQUANT", + "ext_summary_copied": "copiées", + "ext_summary_skip": "déjà présentes", + "ext_summary_miss": "manquantes", + "ext_log_header": "=== IMAGES MANQUANTES ===\n", + "ext_log_source": "Source : ", + "ext_log_summary": "=== RÉSUMÉ ===\n", + "ext_log_games": "Jeux parcourus : ", + "ext_log_copied": "Images copiées : ", + "ext_log_skipped": "Déjà présentes : ", + "ext_log_missing": "Images manquantes : ", + "conv_title": "🖼️ ÉTAPE 2/3 — CONVERSION 128x32", + "conv_png_only": "\n⚠️ INFO : Seuls les fichiers PNG seront convertis en 128x32.\n Les GIF sont conservés tels quels.\n", + "conv_gif_info": "GIF trouvés → conservés sans modification.", + "conv_png_count": "PNG à convertir en 128x32", + "conv_summary_done": "PNG convertis", + "conv_summary_err": "erreurs", + "conv_summary_gif": "GIF conservés", + "conv_no_pillow": "❌ Pillow n'est pas installé. Installe-le avec : pip install Pillow", + "conv_src_where": "\n📍 OÙ SE TROUVENT LES IMAGES À CONVERTIR ?\n (dossier systems/ contenant les sous-dossiers par système)", + "cache_title": "💾 ÉTAPE 3/3 — BUILD GAMES CACHE", + "cache_scan": "[INFO] Scan de : ", + "cache_found_sys": "systèmes,", + "cache_found_games": "jeux trouvés", + "cache_no_sys": "⚠️ Aucun système trouvé. Vérifiez le dossier systems/.", + "cache_size": "Ko", + "cache_sys_where": "\n📍 OÙ SE TROUVE LE DOSSIER SYSTEMS ?", + "cache_sys_detected": "✅ Dossier systems/ détecté : ", + "cache_sys_use": "Utiliser ce dossier ?", + "cache_sys_missing": "⚠️ Aucun dossier systems/ trouvé dans ", + "mode1_title": "MODE 1 — Extraction + Conversion 128x32 + Build Cache", + "mode2_title": "MODE 2 — Extraction Gamelist uniquement", + "mode3_title": "MODE 3 — Conversion 128x32 uniquement", + "mode4_title": "MODE 4 — Build Games Cache uniquement", + "done": "🎉 TERMINÉ !", + "done_sd": "📂 Dossier SD card : ", + "done_cache": "💾 Cache : ", + "done_log": "📋 Log images manquantes : ", + "done_copy_sd": "Copiez le contenu de ce dossier à la racine de votre carte SD.", + "done_extracted": "📂 Images extraites dans : ", + "done_log2": "📋 Log : ", + "done_converted": "📂 Images converties dans : ", + "done_cache2": "💾 Cache généré : ", + "done_copy_cache": "Copiez ces fichiers à la racine de votre carte SD.", + "done_cache_files": "💾 Fichiers générés :", + "src_ok": "✅ Dossier : ", + "roms_ok": "✅ Dossier ROMs : ", + "sysc_title": "MODE 5 — Génération de systems_cache.dat", + "sysc_no_defaults": "⚠️ Aucun dossier _defaults/ trouvé dans systems/.", + "sysc_found": lambda n: f" 📂 {n} systèmes trouvés dans _defaults/", + "sysc_line": lambda t, n: f" {'✅' if t != '?' else '⚠️ '} {t} {n}", + "sysc_unknown": "⚠️ Pas de .gif ni .png trouvé pour ce système.", + "sysc_done": lambda n, p: f"✅ {n} systèmes écrits dans {p}", + "sysc_copy": "Copiez systems_cache.dat à la racine de votre carte SD.", + "sysc_hint": " (L'ESP32 l'utilisera au prochain démarrage sans rescanner)", + "flash_title": "MODE 6 — Copier sur la carte SD", + "flash_no_sdcard": "⚠️ Le dossier sd_card/ est vide ou absent. Lancez un autre mode d'abord.", + "flash_no_win": "⚠️ Ce mode est uniquement disponible sur Windows.", + "flash_admin_warn": "⚠️ Droits administrateur requis. Relancez le script en tant qu'Administrateur.", + "flash_drives_title": "\n💾 LECTEURS DISPONIBLES (amovibles / carte SD) :", + "flash_no_drives": "⚠️ Aucun lecteur amovible détecté. Insérez votre carte SD et réessayez.", + "flash_drive_choice": "Choisissez le lecteur de destination (0 pour revenir) : ", + "flash_drive_warn": "⚠️ Choix invalide.\n", + "flash_drive_sel": lambda d, s: f"\n✅ Destination : {d} ({s})", + "flash_mode_title": "\n⚙️ MODE DE COPIE", + "flash_mode_opt1": "Formater en FAT32 puis copier (ATTENTION : efface tout sur la SD)", + "flash_mode_opt2": "Copier uniquement — écraser les fichiers existants", + "flash_mode_opt3": "Copier uniquement — ignorer les fichiers existants (garder ce qui est déjà là)", + "flash_mode_choice": "Votre choix (0-2) : ", + "flash_mode_warn": "⚠️ Tape 0, 1 ou 2.\n", + "flash_fmt_confirm": lambda d: f"⚠️ TOUTES LES DONNÉES sur {d} seront effacées. Êtes-vous sûr ?", + "flash_fmt_abort": " ↩ Formatage annulé.", + "flash_fmt_start": lambda d: f"🗑️ Formatage de {d} en FAT32...", + "flash_fmt_ok": "✅ Formatage terminé.", + "flash_fmt_err": lambda e: f"❌ Erreur de formatage : {e}", + "flash_copy_start": lambda s, d: f"\n📋 Copie de {s} → {d} (robocopy /MT:32)...", + "flash_copy_ok": "✅ Copie terminée.", + "flash_copy_err": lambda c: f"⚠️ Robocopy terminé avec le code {c} (vérifiez la sortie ci-dessus).", + "main_opt6": "Copier sd_card/ sur la carte SD (rapide, robocopy)", + "main_opt5": "Générer systems_cache.dat (index systèmes ESP32)", + "pause_hint": " ⏸️ Appuie sur [ESC] pour mettre en pause", + "pause_title": "\n⏸️ PAUSE", + "pause_opt1": "Continuer", + "pause_opt2": "Passer à l'étape suivante", + "pause_opt3": "Arrêter le script", + "pause_choice": "Votre choix (1-3) : ", + "pause_warn": "⚠️ Tape 1, 2 ou 3.\n", + "pause_resuming": "▶️ Reprise...", + "pause_skipping": "⏭️ Passage à l'étape suivante...", + "pause_stopping": "🛑 Arrêt demandé.", + "sys_sel_title": "🎮 SYSTÈMES DÉTECTÉS", + "sys_sel_none": "⚠️ Aucun système avec gamelist.xml trouvé dans ce dossier.", + "sys_sel_prompt": "Quels systèmes traiter ?", + "sys_sel_opt_all": "Tous les systèmes", + "sys_sel_opt_pick": "Choisir les systèmes à traiter", + "sys_sel_pick_hint": "Entrez les numéros séparés par des virgules (ex: 1,3,5) ou 0 pour tout sélectionner :", + "sys_sel_warn": "⚠️ Sélection invalide. Réessayez.\n", + "sys_sel_selected": lambda n: f"✅ {n} système(s) sélectionné(s).", + }, + "en": { + "pillow_installing": "⚙️ Pillow is not installed. Installing automatically...", + "pillow_ok": "✅ Pillow installed successfully!\n", + "pillow_fail": "❌ Could not install Pillow.\n Run manually: pip install Pillow\n 128x32 conversion will be disabled.\n", + "main_title": "RetroBoxLED Toolkit for Recalbox", + "dl_title": "🌐 _defaults FOLDER (system images)", + "dl_missing": " ℹ️ No _defaults/ folder found in sd_card/systems/.", + "dl_exists": " ℹ️ _defaults/ already exists in sd_card/systems/.", + "dl_ask_download": "Download _defaults/ from GitHub (RetroBoxLED)?", + "dl_ask_update": "Update _defaults/ from GitHub (RetroBoxLED)?", + "dl_skip": " ⏭️ Download skipped.", + "dl_starting": "⬇️ Downloading files from GitHub...", + "dl_file_ok": lambda n, i, t: f" {i:4d}/{t} ✅ {n}", + "dl_file_err": lambda n, e: f" ⚠️ {n} — {e}", + "dl_done": lambda n: f"✅ {n} files downloaded into _defaults/", + "dl_fail_api": "❌ GitHub API unreachable. Check your internet connection.", + "dl_replacing": "🗑️ Replacing existing _defaults/...", + "main_prompt": "What do you want to do?", + "main_opt1": "Gamelist extraction + 128x32 conversion + Build cache (ALL)", + "main_opt2": "Gamelist image extraction only", + "main_opt3": "128x32 conversion only", + "main_opt4": "Build games_cache.bin only", + "main_opt7": "Graphical interface (Tkinter)", + "main_choice": "Your choice (1-7): ", + "main_opt_quit": "Quit", + "main_warn": "⚠️ Enter a number between 0 and 7.\n", + "back": "↩ Go back", + "back_main": "\n ↩ Back to main menu...", + "back_roms": "\n ↩ Back to ROMs folder selection...", + "yes_no": "(y/n)", + "yes_vals": ("y", "yes", "o", "oui"), + "no_vals": ("n", "no", "non"), + "warn_yn": "⚠️ Type y or n.\n", + "warn_choice": "⚠️ Type 0, 1 or 2.\n", + "after_menu": "What do you want to do next?", + "after_opt1": "Back to main menu", + "after_opt6": "Copy to SD card now (mode 6)", + "after_opt_files": "Copy generated files to SD card", + "press_enter": "Press Enter to close...", + "press_enter_cont": "Press Enter to continue anyway...", + "path_local": " 1 → Local drive (e.g.: D:\\Recalbox\\roms)", + "path_network": " 2 → Network / NAS (e.g.: \\\\192.168.1.1\\share\\roms)", + "path_choice": "Your choice (0, 1 or 2): ", + "path_local_lbl": "Folder path (0 to go back): ", + "path_net_lbl": "Network path (0 to go back): ", + "path_not_found": "❌ Folder not found. Check the path and try again.\n", + "sd_erase_ask": "Do you want to completely erase it before continuing?", + "sd_erased": "🗑️ Folder erased and recreated.", + "sd_erase_err": "❌ Error while erasing. Close any open files in sd_card/ and try again.", + "sd_kept": " ℹ️ Content kept, existing files will be overwritten or skipped.", + "ext_title": "🚀 STEP 1/3 — IMAGE EXTRACTION", + "ext_roms_where": "\n📍 WHERE ARE YOUR ROMS?", + "ext_systems": "systems with gamelist.xml detected", + "ext_games_found": "games found", + "ext_xml_err": "XML ERROR", + "ext_already": "already exists", + "ext_missing_tag": "MISSING", + "ext_summary_copied": "copied", + "ext_summary_skip": "already present", + "ext_summary_miss": "missing", + "ext_log_header": "=== MISSING IMAGES ===\n", + "ext_log_source": "Source: ", + "ext_log_summary": "=== SUMMARY ===\n", + "ext_log_games": "Games scanned : ", + "ext_log_copied": "Images copied : ", + "ext_log_skipped": "Already present : ", + "ext_log_missing": "Missing images : ", + "conv_title": "🖼️ STEP 2/3 — 128x32 CONVERSION", + "conv_png_only": "\n⚠️ INFO: Only PNG files will be converted to 128x32.\n GIFs are kept as-is.\n", + "conv_gif_info": "GIF found → kept without modification.", + "conv_png_count": "PNG to convert to 128x32", + "conv_summary_done": "PNG converted", + "conv_summary_err": "errors", + "conv_summary_gif": "GIF kept", + "conv_no_pillow": "❌ Pillow is not installed. Install it with: pip install Pillow", + "conv_src_where": "\n📍 WHERE ARE THE IMAGES TO CONVERT?\n (systems/ folder containing subfolders per system)", + "cache_title": "💾 STEP 3/3 — BUILD GAMES CACHE", + "cache_scan": "[INFO] Scanning: ", + "cache_found_sys": "systems,", + "cache_found_games": "games found", + "cache_no_sys": "⚠️ No systems found. Check the systems/ folder.", + "cache_size": "KB", + "cache_sys_where": "\n📍 WHERE IS THE SYSTEMS FOLDER?", + "cache_sys_detected": "✅ systems/ folder detected: ", + "cache_sys_use": "Use this folder?", + "cache_sys_missing": "⚠️ No systems/ folder found in ", + "mode1_title": "MODE 1 — Extraction + 128x32 Conversion + Build Cache", + "mode2_title": "MODE 2 — Gamelist Extraction only", + "mode3_title": "MODE 3 — 128x32 Conversion only", + "mode4_title": "MODE 4 — Build Games Cache only", + "done": "🎉 DONE!", + "done_sd": "📂 SD card folder : ", + "done_cache": "💾 Cache : ", + "done_log": "📋 Missing images log : ", + "done_copy_sd": "Copy the contents of this folder to the root of your SD card.", + "done_extracted": "📂 Images extracted to: ", + "done_log2": "📋 Log: ", + "done_converted": "📂 Images converted in: ", + "done_cache2": "💾 Cache generated: ", + "done_copy_cache": "Copy these files to the root of your SD card.", + "done_cache_files": "💾 Generated files:", + "src_ok": "✅ Folder: ", + "roms_ok": "✅ ROMs folder: ", + "sysc_title": "MODE 5 — Build systems_cache.dat", + "sysc_no_defaults": "⚠️ No _defaults/ folder found in systems/.", + "sysc_found": lambda n: f" 📂 {n} systems found in _defaults/", + "sysc_line": lambda t, n: f" {'✅' if t != '?' else '⚠️ '} {t} {n}", + "sysc_unknown": "⚠️ No .gif or .png found for this system.", + "sysc_done": lambda n, p: f"✅ {n} systems written to {p}", + "sysc_copy": "Copy systems_cache.dat to the root of your SD card.", + "sysc_hint": " (The ESP32 will use this on next boot instead of rescanning)", + "flash_title": "MODE 6 — Copy to SD card", + "flash_no_sdcard": "⚠️ sd_card/ folder is empty or missing. Run another mode first.", + "flash_no_win": "⚠️ This mode is only available on Windows.", + "flash_admin_warn": "⚠️ Administrator rights required. Please relaunch as Administrator.", + "flash_drives_title": "\n💾 AVAILABLE DRIVES (removable / SD card) :", + "flash_no_drives": "⚠️ No removable drive detected. Insert your SD card and try again.", + "flash_drive_choice": "Choose destination drive (0 to go back): ", + "flash_drive_warn": "⚠️ Invalid choice.\n", + "flash_drive_sel": lambda d, s: f"\n✅ Destination: {d} ({s})", + "flash_mode_title": "\n⚙️ COPY MODE", + "flash_mode_opt1": "Format FAT32 then copy (WARNING: erases everything on the SD)", + "flash_mode_opt2": "Copy only — overwrite existing files", + "flash_mode_opt3": "Copy only — skip existing files (keep what's already there)", + "flash_mode_choice": "Your choice (0-2): ", + "flash_mode_warn": "⚠️ Type 0, 1 or 2.\n", + "flash_fmt_confirm": lambda d: f"⚠️ ALL DATA on {d} will be erased. Are you sure?", + "flash_fmt_abort": " ↩ Format cancelled.", + "flash_fmt_start": lambda d: f"🗑️ Formatting {d} in FAT32...", + "flash_fmt_ok": "✅ Format complete.", + "flash_fmt_err": lambda e: f"❌ Format error: {e}", + "flash_copy_start": lambda s, d: f"\n📋 Copying {s} → {d} (robocopy /MT:32)...", + "flash_copy_ok": "✅ Copy complete.", + "flash_copy_err": lambda c: f"⚠️ Robocopy finished with code {c} (check output above).", + "main_opt6": "Copy sd_card/ to SD card (fast, robocopy)", + "main_opt5": "Build systems_cache.dat (ESP32 system index)", + "pause_hint": " ⏸️ Press [ESC] to pause", + "pause_title": "\n⏸️ PAUSED", + "pause_opt1": "Continue", + "pause_opt2": "Skip to next step", + "pause_opt3": "Stop the script", + "pause_choice": "Your choice (1-3): ", + "pause_warn": "⚠️ Type 1, 2 or 3.\n", + "pause_resuming": "▶️ Resuming...", + "pause_skipping": "⏭️ Skipping to next step...", + "pause_stopping": "🛑 Stop requested.", + "sys_sel_title": "🎮 DETECTED SYSTEMS", + "sys_sel_none": "⚠️ No system with gamelist.xml found in this folder.", + "sys_sel_prompt": "Which systems to process?", + "sys_sel_opt_all": "All systems", + "sys_sel_opt_pick": "Choose specific systems", + "sys_sel_pick_hint": "Enter numbers separated by commas (e.g. 1,3,5) or 0 to select all:", + "sys_sel_warn": "⚠️ Invalid selection. Try again.\n", + "sys_sel_selected": lambda n: f"✅ {n} system(s) selected.", + }, + "es": { + "pillow_installing": "⚙️ Pillow no está instalado. Instalando automáticamente...", + "pillow_ok": "✅ ¡Pillow instalado correctamente!\n", + "pillow_fail": "❌ No se pudo instalar Pillow.\n Ejecútalo manualmente: pip install Pillow\n La conversión 128x32 estará desactivada.\n", + "main_title": "RetroBoxLED Toolkit for Recalbox", + "dl_title": "🌐 CARPETA _defaults (imágenes de sistemas)", + "dl_missing": " ℹ️ No se encontró carpeta _defaults/ en sd_card/systems/.", + "dl_exists": " ℹ️ La carpeta _defaults/ ya existe en sd_card/systems/.", + "dl_ask_download": "¿Descargar _defaults/ desde GitHub (RetroBoxLED)?", + "dl_ask_update": "¿Actualizar _defaults/ desde GitHub (RetroBoxLED)?", + "dl_skip": " ⏭️ Descarga omitida.", + "dl_starting": "⬇️ Descargando archivos desde GitHub...", + "dl_file_ok": lambda n, i, t: f" {i:4d}/{t} ✅ {n}", + "dl_file_err": lambda n, e: f" ⚠️ {n} — {e}", + "dl_done": lambda n: f"✅ {n} archivos descargados en _defaults/", + "dl_fail_api": "❌ API de GitHub inaccesible. Verifica tu conexión a internet.", + "dl_replacing": "🗑️ Reemplazando _defaults/ existente...", + "main_prompt": "¿Qué desea hacer?", + "main_opt1": "Extracción gamelist + Conversión 128x32 + Build cache (TODO)", + "main_opt2": "Solo extraer imágenes de los gamelists", + "main_opt3": "Solo convertir imágenes a 128x32", + "main_opt4": "Solo construir games_cache.bin", + "main_opt7": "Interfaz gráfica (Tkinter)", + "main_choice": "Su eleccion (1-7): ", + "main_opt_quit": "Salir", + "main_warn": "⚠️ Escribe un número entre 0 y 7.\n", + "back": "↩ Volver atrás", + "back_main": "\n ↩ Volver al menú principal...", + "back_roms": "\n ↩ Volver a la selección de carpeta ROMs...", + "yes_no": "(s/n)", + "yes_vals": ("s", "si", "sí", "y", "yes", "o", "oui"), + "no_vals": ("n", "no", "non"), + "warn_yn": "⚠️ Escribe s o n.\n", + "warn_choice": "⚠️ Escribe 0, 1 o 2.\n", + "after_menu": "¿Qué desea hacer a continuación?", + "after_opt1": "Volver al menú principal", + "after_opt6": "Copiar a la tarjeta SD ahora (modo 6)", + "after_opt_files": "Copiar los archivos generados a la tarjeta SD", + "press_enter": "Pulsa Intro para cerrar...", + "press_enter_cont": "Pulsa Intro para continuar de todas formas...", + "path_local": " 1 → Disco local (ej: D:\\Recalbox\\roms)", + "path_network": " 2 → Red / NAS (ej: \\\\192.168.1.1\\share\\roms)", + "path_choice": "Su elección (0, 1 o 2): ", + "path_local_lbl": "Ruta de la carpeta (0 para volver): ", + "path_net_lbl": "Ruta de red (0 para volver): ", + "path_not_found": "❌ Carpeta no encontrada. Verifica la ruta e inténtalo de nuevo.\n", + "sd_erase_ask": "¿Desea borrarla completamente antes de continuar?", + "sd_erased": "🗑️ Carpeta borrada y recreada.", + "sd_erase_err": "❌ Error al borrar. Cierra los archivos abiertos en sd_card/ e inténtalo de nuevo.", + "sd_kept": " ℹ️ Contenido conservado, los archivos existentes serán sobreescritos o ignorados.", + "ext_title": "🚀 PASO 1/3 — EXTRACCIÓN DE IMÁGENES", + "ext_roms_where": "\n📍 ¿DÓNDE ESTÁN SUS ROMS?", + "ext_systems": "sistemas con gamelist.xml detectados", + "ext_games_found": "juegos encontrados", + "ext_xml_err": "ERROR XML", + "ext_already": "ya existe", + "ext_missing_tag": "FALTA", + "ext_summary_copied": "copiadas", + "ext_summary_skip": "ya presentes", + "ext_summary_miss": "faltantes", + "ext_log_header": "=== IMÁGENES FALTANTES ===\n", + "ext_log_source": "Fuente: ", + "ext_log_summary": "=== RESUMEN ===\n", + "ext_log_games": "Juegos analizados : ", + "ext_log_copied": "Imágenes copiadas : ", + "ext_log_skipped": "Ya presentes : ", + "ext_log_missing": "Imágenes faltantes: ", + "conv_title": "🖼️ PASO 2/3 — CONVERSIÓN 128x32", + "conv_png_only": "\n⚠️ INFO: Solo los archivos PNG serán convertidos a 128x32.\n Los GIF se conservan tal cual.\n", + "conv_gif_info": "GIF encontrados → conservados sin modificación.", + "conv_png_count": "PNG a convertir a 128x32", + "conv_summary_done": "PNG convertidos", + "conv_summary_err": "errores", + "conv_summary_gif": "GIF conservados", + "conv_no_pillow": "❌ Pillow no está instalado. Instálalo con: pip install Pillow", + "conv_src_where": "\n📍 ¿DÓNDE ESTÁN LAS IMÁGENES A CONVERTIR?\n (carpeta systems/ con subcarpetas por sistema)", + "cache_title": "💾 PASO 3/3 — BUILD GAMES CACHE", + "cache_scan": "[INFO] Analizando: ", + "cache_found_sys": "sistemas,", + "cache_found_games": "juegos encontrados", + "cache_no_sys": "⚠️ No se encontraron sistemas. Verifica la carpeta systems/.", + "cache_size": "KB", + "cache_sys_where": "\n📍 ¿DÓNDE ESTÁ LA CARPETA SYSTEMS?", + "cache_sys_detected": "✅ Carpeta systems/ detectada: ", + "cache_sys_use": "¿Usar esta carpeta?", + "cache_sys_missing": "⚠️ No se encontró carpeta systems/ en ", + "mode1_title": "MODO 1 — Extracción + Conversión 128x32 + Build Cache", + "mode2_title": "MODO 2 — Solo Extracción Gamelist", + "mode3_title": "MODO 3 — Solo Conversión 128x32", + "mode4_title": "MODO 4 — Solo Build Games Cache", + "done": "🎉 ¡TERMINADO!", + "done_sd": "📂 Carpeta SD card : ", + "done_cache": "💾 Cache : ", + "done_log": "📋 Log imágenes faltantes: ", + "done_copy_sd": "Copia el contenido de esta carpeta en la raíz de tu tarjeta SD.", + "done_extracted": "📂 Imágenes extraídas en: ", + "done_log2": "📋 Log: ", + "done_converted": "📂 Imágenes convertidas en: ", + "done_cache2": "💾 Cache generado: ", + "done_copy_cache": "Copia estos archivos en la raíz de tu tarjeta SD.", + "done_cache_files": "💾 Archivos generados:", + "src_ok": "✅ Carpeta: ", + "roms_ok": "✅ Carpeta ROMs: ", + "sysc_title": "MODO 5 — Generar systems_cache.dat", + "sysc_no_defaults": "⚠️ No se encontró carpeta _defaults/ en systems/.", + "sysc_found": lambda n: f" 📂 {n} sistemas encontrados en _defaults/", + "sysc_line": lambda t, n: f" {'✅' if t != '?' else '⚠️ '} {t} {n}", + "sysc_unknown": "⚠️ No se encontró .gif ni .png para este sistema.", + "sysc_done": lambda n, p: f"✅ {n} sistemas escritos en {p}", + "sysc_copy": "Copia systems_cache.dat en la raíz de tu tarjeta SD.", + "sysc_hint": " (El ESP32 lo usará en el próximo arranque sin rescanear)", + "flash_title": "MODO 6 — Copiar a la tarjeta SD", + "flash_no_sdcard": "⚠️ La carpeta sd_card/ está vacía o no existe. Ejecuta otro modo primero.", + "flash_no_win": "⚠️ Este modo solo está disponible en Windows.", + "flash_admin_warn": "⚠️ Se requieren derechos de administrador. Relanza el script como Administrador.", + "flash_drives_title": "\n💾 UNIDADES DISPONIBLES (extraíbles / tarjeta SD) :", + "flash_no_drives": "⚠️ No se detectó ninguna unidad extraíble. Inserta tu tarjeta SD e inténtalo de nuevo.", + "flash_drive_choice": "Elige la unidad de destino (0 para volver): ", + "flash_drive_warn": "⚠️ Elección inválida.\n", + "flash_drive_sel": lambda d, s: f"\n✅ Destino: {d} ({s})", + "flash_mode_title": "\n⚙️ MODO DE COPIA", + "flash_mode_opt1": "Formatear en FAT32 y copiar (ATENCIÓN: borra todo en la SD)", + "flash_mode_opt2": "Solo copiar — sobreescribir archivos existentes", + "flash_mode_opt3": "Solo copiar — ignorar archivos existentes (conservar lo que ya está)", + "flash_mode_choice": "Su elección (0-2): ", + "flash_mode_warn": "⚠️ Escribe 0, 1 o 2.\n", + "flash_fmt_confirm": lambda d: f"⚠️ TODOS LOS DATOS en {d} serán borrados. ¿Estás seguro?", + "flash_fmt_abort": " ↩ Formateo cancelado.", + "flash_fmt_start": lambda d: f"🗑️ Formateando {d} en FAT32...", + "flash_fmt_ok": "✅ Formateo completado.", + "flash_fmt_err": lambda e: f"❌ Error de formateo: {e}", + "flash_copy_start": lambda s, d: f"\n📋 Copiando {s} → {d} (robocopy /MT:32)...", + "flash_copy_ok": "✅ Copia completada.", + "flash_copy_err": lambda c: f"⚠️ Robocopy terminó con código {c} (revisa la salida anterior).", + "main_opt6": "Copiar sd_card/ a la tarjeta SD (rápido, robocopy)", + "main_opt5": "Generar systems_cache.dat (índice de sistemas ESP32)", + "pause_hint": " ⏸️ Pulsa [ESC] para pausar", + "pause_title": "\n⏸️ PAUSADO", + "pause_opt1": "Continuar", + "pause_opt2": "Saltar al siguiente paso", + "pause_opt3": "Detener el script", + "pause_choice": "Su elección (1-3): ", + "pause_warn": "⚠️ Escribe 1, 2 o 3.\n", + "pause_resuming": "▶️ Reanudando...", + "pause_skipping": "⏭️ Saltando al siguiente paso...", + "pause_stopping": "🛑 Parada solicitada.", + "sys_sel_title": "🎮 SISTEMAS DETECTADOS", + "sys_sel_none": "⚠️ No se encontró ningún sistema con gamelist.xml en esta carpeta.", + "sys_sel_prompt": "¿Qué sistemas procesar?", + "sys_sel_opt_all": "Todos los sistemas", + "sys_sel_opt_pick": "Elegir sistemas específicos", + "sys_sel_pick_hint": "Introduce los números separados por comas (ej: 1,3,5) o 0 para todos:", + "sys_sel_warn": "⚠️ Selección inválida. Inténtalo de nuevo.\n", + "sys_sel_selected": lambda n: f"✅ {n} sistema(s) seleccionado(s).", + }, +} + +# Global translation dict (set in main after language selection) +T = TRANSLATIONS["fr"] + + +def tr(key): + return T[key] + + +# ───────────────────────────────────────────────────────────────────────────── +# INSTALLATION AUTOMATIQUE DES DÉPENDANCES +# ───────────────────────────────────────────────────────────────────────────── + +PIL_AVAILABLE = False + + +def ensure_dependencies(): + global PIL_AVAILABLE + try: + from PIL import Image + + PIL_AVAILABLE = True + return + except ImportError: + print(tr("pillow_installing")) + import subprocess + + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "Pillow"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + print(tr("pillow_ok")) + PIL_AVAILABLE = True + except subprocess.CalledProcessError: + print(tr("pillow_fail")) + PIL_AVAILABLE = False + + +# ───────────────────────────────────────────────────────────────────────────── +# CONSTANTES +# ───────────────────────────────────────────────────────────────────────────── + +TARGET_W = 128 +TARGET_H = 32 +EXTENSIONS_CACHE = {".gif": 0x67, ".png": 0x70} +LETTERS = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ" +NB_LETTERS = len(LETTERS) + +# ───────────────────────────────────────────────────────────────────────────── +# UTILITAIRES COMMUNS +# ───────────────────────────────────────────────────────────────────────────── + + +def sep(char="═", width=70): + print(char * width) + + +def title(text): + sep() + print(f" {text}") + sep() + + +def ask_yes_no(question): + yn = tr("yes_no") + yes = tr("yes_vals") + no = tr("no_vals") + while True: + r = input(f"{question} {yn} : ").strip().lower() + if r in yes: + return True + if r in no: + return False + print(tr("warn_yn")) + + +def ask_path(must_exist=True): + """Demande un chemin local ou réseau. Retourne Path ou None (retour arrière).""" + while True: + print() + print(tr("path_local")) + print(tr("path_network")) + print(f" 0 → {tr('back')}") + print() + choix = input(tr("path_choice")).strip() + if choix == "0": + return None + if choix not in ("1", "2"): + print(tr("warn_choice")) + continue + lbl = tr("path_local_lbl") if choix == "1" else tr("path_net_lbl") + chemin = input(lbl).strip().strip('"') + if chemin == "0": + continue + p = Path(chemin) + if not must_exist or (p.exists() and p.is_dir()): + return p + print(tr("path_not_found")) + + +def sanitize_filename(name: str) -> str: + for ch in r'\/:*?"<>|': + name = name.replace(ch, "_") + name = name.replace(" ", "") + return name.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# SD CARD +# ───────────────────────────────────────────────────────────────────────────── + + +def get_sd_card_dir(script_dir: Path) -> Path: + """ + Emplacement GUI/Tk : dossier temporaire dans %LOCALAPPDATA%/Temp. + On utilise un sous-dossier pour éviter de polluer le temp global. + """ + local_appdata = os.environ.get("LOCALAPPDATA") + if local_appdata: + base = Path(local_appdata) / "Temp" + else: + # fallback (devrait être rare sur Windows) + base = Path(tempfile.gettempdir()) # type: ignore[name-defined] + + return base / "RetroBoxLED" + + +def prepare_sd_card(sd_dir: Path, interactive: bool = True): + if sd_dir.exists(): + items = list(sd_dir.iterdir()) + if items: + print(f"\n⚠️ '{sd_dir}' ({len(items)} items)") + if not interactive: + # GUI: pas de question clavier. On garde le contenu. + print(tr("sd_kept")) + return + if ask_yes_no(tr("sd_erase_ask")): + try: + shutil.rmtree(sd_dir) + sd_dir.mkdir(parents=True) + print(tr("sd_erased")) + except Exception as e: + print(f"{tr('sd_erase_err')}\n {e}") + input(tr("press_enter_cont")) + sd_dir.mkdir(parents=True, exist_ok=True) + else: + print(tr("sd_kept")) + else: + sd_dir.mkdir(parents=True, exist_ok=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# EXTRACTION GAMELIST +# ───────────────────────────────────────────────────────────────────────────── + +_INVALID_XML_CHARS = re.compile( + r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]" r"|&#(?:x[0-9a-fA-F]+|\d+);" +) + + +def _is_valid_codepoint(m: re.Match) -> bool: + s = m.group() + if not s.startswith("&#"): + return False + inner = s[2:-1] + code = int(inner[1:], 16) if inner.startswith("x") else int(inner) + return ( + code == 0x9 + or code == 0xA + or code == 0xD + or 0x20 <= code <= 0xD7FF + or 0xE000 <= code <= 0xFFFD + or 0x10000 <= code <= 0x10FFFF + ) + + +def sanitize_xml(raw: bytes) -> bytes: + text = raw.decode("utf-8", errors="replace") + cleaned = _INVALID_XML_CHARS.sub( + lambda m: "" if not _is_valid_codepoint(m) else m.group(), text + ) + return cleaned.encode("utf-8") + + +def resolve_image_path(sys_dir: Path, raw_path: str) -> Path: + p = raw_path.strip() + if p.startswith("/"): + return Path(p) + return sys_dir / p + + +def parse_gamelist(gamelist_path: Path): + raw = gamelist_path.read_bytes() + cleaned = sanitize_xml(raw) + root = ET.fromstring(cleaned) + return root.findall(".//game") + + +def ask_extraction_config(): + """ + Depuis la MAJ Recalbox, la balise remplace / + Les images sont copiées directement dans systems// (pas de sous-dossier). + Retourne [(tag, folder)] fixe. + """ + return [("logo", "")] + + +def extract_system( + sys_dir, + systems_out, + tag_configs, + sys_index, + total_systems, + log_file, + progress_cb=None, + progress_global_total_images: int = 0, + progress_global_done_offset: int = 0, +): + sys_name = sys_dir.name + print(f"\n[{sys_index}/{total_systems}] 📁 {sys_name}") + + try: + games = parse_gamelist(sys_dir / "gamelist.xml") + except ET.ParseError as e: + msg = f"[{sys_name}] {tr('ext_xml_err')} : {e}" + print(f" ❌ {msg}") + log_file.write(msg + "\n") + return 0, 0, 0, 0 + + total = len(games) + copied = 0 + skipped = 0 + missing = 0 + print(f" 🎮 {total} {tr('ext_games_found')}") + total_images_global = max(progress_global_total_images, 1) + + for i, game in enumerate(games, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + path_elem = game.find("path") + if path_elem is None: + missing += 1 + continue + + raw_path = unquote(path_elem.text or "").strip() + if not raw_path: + missing += 1 + continue + + game_name = sanitize_filename(Path(raw_path).stem) + + for tag, folder in tag_configs: + img_elem = game.find(tag) + if img_elem is None or not (img_elem.text or "").strip(): + missing += 1 + continue + + image_raw = unquote(img_elem.text.strip()) + src_image = resolve_image_path(sys_dir, image_raw) + ext = src_image.suffix or ".png" + + dst_dir = systems_out / sys_name / folder + dst_dir.mkdir(parents=True, exist_ok=True) + dst_image = dst_dir / f"{game_name}{ext}" + + current_file = f"{game_name}{ext}" + if progress_cb is not None: + # Détail “images copiées/total + fichier en cours”. + # On envoie la progression globale (toutes les images, tous systèmes) + # pour que le % soit cohérent. + progress_cb( + "extraction_imgs", + progress_global_done_offset + copied + skipped, + progress_global_total_images, + f"{copied}/{total} — {current_file}", + ) + + if dst_image.exists(): + skipped += 1 + print( + f" {i:4d}/{total} ⏭️ [{folder}] {game_name}{ext} ({tr('ext_already')})" + ) + continue + + if not src_image.exists(): + missing += 1 + print( + f" {i:4d}/{total} ⚠️ {tr('ext_missing_tag')} ({tag}): {src_image.name}" + ) + log_file.write(f"[{sys_name}] {game_name} ({tag}) → {src_image}\n") + continue + + shutil.copy2(src_image, dst_image) + copied += 1 + + if progress_cb is not None: + # Progression globale après copie. + progress_cb( + "extraction_imgs", + progress_global_done_offset + copied + skipped, + progress_global_total_images, + f"{copied}/{total} — {current_file}", + ) + + print(f" {i:4d}/{total} ✅ [{folder}] {game_name}{ext}") + time.sleep(0.003) + + print( + f" → ✅ {copied} {tr('ext_summary_copied')} | " + f"⏭️ {skipped} {tr('ext_summary_skip')} | " + f"⚠️ {missing} {tr('ext_summary_miss')}" + ) + return total, copied, skipped, missing + + +def ask_system_selection(roms_root: Path): + """ + Liste les systèmes détectés dans roms_root et propose à l'utilisateur + de choisir lesquels traiter. Retourne la liste des Path sélectionnés, + ou None si l'utilisateur revient en arrière. + """ + systems = sorted( + [ + d + for d in roms_root.iterdir() + if d.is_dir() and (d / "gamelist.xml").exists() + ], + key=lambda d: d.name.lower(), + ) + + sep("─") + print(f"\n{tr('sys_sel_title')}") + sep("─") + + if not systems: + print(tr("sys_sel_none")) + return None + + for i, s in enumerate(systems, 1): + print(f" {i:3d} → {s.name}") + print() + print(f" {tr('sys_sel_prompt')}") + print() + print(f" 1 → {tr('sys_sel_opt_all')}") + print(f" 2 → {tr('sys_sel_opt_pick')}") + print(f" 0 → {tr('back')}") + print() + + while True: + raw = input(" > ").strip() + if raw == "0": + return None + if raw == "1": + print(tr("sys_sel_selected")(len(systems))) + return systems + if raw == "2": + break + print(tr("sys_sel_warn")) + + # Sélection manuelle + print() + print(f" {tr('sys_sel_pick_hint')}") + print() + while True: + raw = input(" > ").strip() + if raw == "0": + print(tr("sys_sel_selected")(len(systems))) + return systems + parts = [p.strip() for p in raw.split(",") if p.strip()] + selected = [] + valid = True + seen = set() + for p in parts: + if not p.isdigit(): + valid = False + break + idx = int(p) + if idx < 1 or idx > len(systems) or idx in seen: + valid = False + break + seen.add(idx) + selected.append(systems[idx - 1]) + if valid and selected: + print(tr("sys_sel_selected")(len(selected))) + return selected + print(tr("sys_sel_warn")) + + +def run_extraction( + roms_root, + systems_out, + tag_configs, + log_file, + selected_systems=None, + progress_cb=None, + listen_keyboard: bool = True, +): + if selected_systems is not None: + systems = selected_systems + else: + systems = [ + d + for d in roms_root.iterdir() + if d.is_dir() and (d / "gamelist.xml").exists() + ] + total_systems = len(systems) + print(f"\n📂 {total_systems} {tr('ext_systems')}") + print(tr("pause_hint")) + + # Pour que le % reflète la progression globale (images), + # on calcule le total global de "games" sur tous les systèmes. + global_total_images = 0 + for sys_dir in systems: + try: + games = parse_gamelist(sys_dir / "gamelist.xml") + global_total_images += len(games) + except Exception: + # Si un système a un XML invalide, il ne contribuera pas au total. + pass + global_total_images = max(global_total_images, 1) + + # compteur global d’images "faites" = copiées + déjà présentes (skipped) + global_done_images = 0 + + PAUSE.start(listen_keyboard=listen_keyboard) + grand = {"games": 0, "copied": 0, "skipped": 0, "missing": 0, "done": 0} + for idx, sys_dir in enumerate(systems, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + if progress_cb is not None: + sys_label = f"{idx}/{total_systems} — {sys_dir.name}" + progress_cb( + "extraction", + global_done_images, + global_total_images, + sys_label, + ) + + g, c, s, m = extract_system( + sys_dir, + systems_out, + tag_configs, + idx, + total_systems, + log_file, + progress_cb=progress_cb, + progress_global_total_images=global_total_images, + progress_global_done_offset=global_done_images, + ) + + global_done_images += c + s + + grand["games"] += g + grand["copied"] += c + grand["skipped"] += s + grand["missing"] += m + if g > 0: + grand["done"] += 1 + + PAUSE.stop() + + return grand, total_systems + + +# ───────────────────────────────────────────────────────────────────────────── +# CONVERSION 128x32 +# ───────────────────────────────────────────────────────────────────────────── + + +def convert_image_file(src: Path, dst: Path): + from PIL import Image + + with Image.open(src) as img: + img = img.convert("RGBA") + orig_w, orig_h = img.size + ratio = min(TARGET_W / orig_w, TARGET_H / orig_h) + new_w = int(orig_w * ratio) + new_h = int(orig_h * ratio) + resized = img.resize((new_w, new_h), Image.LANCZOS) + canvas = Image.new("RGBA", (TARGET_W, TARGET_H), (0, 0, 0, 255)) + offset_x = (TARGET_W - new_w) // 2 + offset_y = (TARGET_H - new_h) // 2 + canvas.paste(resized, (offset_x, offset_y), resized) + canvas.convert("RGB").save(dst, "PNG", optimize=False, interlace=False) + + +def open_folder_in_explorer(folder: Path) -> None: + try: + folder = folder.resolve() + except Exception: + folder = folder + + try: + if os.name == "nt": + os.startfile(str(folder)) # type: ignore[attr-defined] + return + except Exception: + pass + + # fallback (non-Windows) + try: + import subprocess + + subprocess.Popen(["xdg-open", str(folder)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # type: ignore[call-arg] + except Exception: + pass + + +def popup_confirm_before_conversion(out_dir: Path) -> bool: + """ + Popup Tkinter (bloquant) : + - Continuer -> True + - Explorer -> ouvre out_dir + - X -> False + Si Tkinter échoue, on retourne True (fallback). + """ + try: + import tkinter as tk + except Exception: + return True + + ok_holder = {"ok": False} + + root = tk.Tk() + root.title("RetroBoxLED — Conversion 128x32") + root.resizable(False, False) + + # Message + msg = tk.Label( + root, + text=f"Préparation conversion 128x32.\n\nSortie :\n{str(out_dir)}", + justify="left", + padx=14, + pady=12, + ) + msg.pack() + + buttons = tk.Frame(root, padx=10, pady=10) + buttons.pack() + + def on_explore(): + open_folder_in_explorer(out_dir) + + def on_continue(): + ok_holder["ok"] = True + root.destroy() + + def on_close(): + root.destroy() + + explore_btn = tk.Button( + buttons, text="Explorer dossier", width=18, command=on_explore + ) + explore_btn.grid(row=0, column=0, padx=8, pady=6) + + cont_btn = tk.Button(buttons, text="Continuer", width=18, command=on_continue) + cont_btn.grid(row=0, column=1, padx=8, pady=6) + + root.protocol("WM_DELETE_WINDOW", on_close) + root.mainloop() + return bool(ok_holder["ok"]) + + +def run_conversion( + systems_dir: Path, + progress_cb=None, + listen_keyboard: bool = True, +): + if not PIL_AVAILABLE: + print(tr("conv_no_pillow")) + return + + print(tr("conv_png_only")) + + png_files = list(systems_dir.rglob("*.png")) + gif_files = list(systems_dir.rglob("*.gif")) + total = len(png_files) + done = errors = 0 + + # Assure que l'UI passe en "Conversion" même si total == 0 + if progress_cb is not None: + progress_cb( + "conversion", + 0, + total, + "aucun PNG à convertir" if total == 0 else "conversion en cours", + ) + + if gif_files: + print(f" 🎞️ {len(gif_files)} {tr('conv_gif_info')}") + print(f" 🖼️ {total} {tr('conv_png_count')}") + print(tr("pause_hint")) + sep("─") + + PAUSE.start(listen_keyboard=listen_keyboard) + for i, src in enumerate(png_files, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + if progress_cb is not None: + # Envoi : system|filename (pour que la GUI affiche system en ligne 1, + # et le filename tronqué en ligne 2 sans pousser la mise en page) + progress_cb("conversion", i, total, f"{src.parent.name}|{src.name}") + + try: + if "_defaults" in str(src.parent).lower(): + continue + convert_image_file(src, src) + done += 1 + print(f" {i:5d}/{total} ✅ {src.relative_to(systems_dir)}") + except Exception as e: + errors += 1 + print(f" {i:5d}/{total} ❌ {src.relative_to(systems_dir)} — {e}") + PAUSE.stop() + + sep("─") + print( + f"✅ {done} {tr('conv_summary_done')} | " + f"❌ {errors} {tr('conv_summary_err')} | " + f"🎞️ {len(gif_files)} {tr('conv_summary_gif')}" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# BUILD GAMES CACHE — index bigramme 702 entrees +# +# Structure de l'index par systeme : 702 x 4 bytes (offsets absolus) +# Index 0 = '#' (chiffres, tirets, etc.) +# Index 1 = 'A' (jeux commencant par A + caractere non-lettre) +# Index 2..27 = 'AA'..'AZ' +# Index 28 = 'B' +# Index 29..54 = 'BA'..'BZ' +# ... +# Index 703 = 'Z' +# Total = 1 + 26 * 27 = 703 (indices 0..702) +# ───────────────────────────────────────────────────────────────────────────── + +NB_IDX = 703 # nombre total d'entrees dans la table bigramme + + +def bigram_index(name): + """ + Calcule l'index bigramme (0..702) pour un nom de jeu. + - Commence par non-lettre -> 0 (#) + - Commence par A seul (ex: "a1") -> 1 + - Commence par AA..AZ -> 2..27 + - Commence par B seul -> 28 + - etc. + """ + if not name: + return 0 + c1 = name[0].upper() + if not c1.isalpha(): + return 0 # '#' + i1 = ord(c1) - ord("A") # 0..25 + base = 1 + i1 * 27 # base pour la lettre c1 + + if len(name) < 2: + return base # lettre seule + + c2 = name[1].upper() + if not c2.isalpha(): + return base # lettre seule (2eme char non-lettre) + + i2 = ord(c2) - ord("A") # 0..25 + return base + i2 + 1 # base + 1..26 + + +def collect_games_for_folder(systems_dir: Path, folder: str): + """ + Collecte les jeux pour un dossier image specifique. + folder = "" pour la racine du systeme, + folder = "" pour la racine du système (pas de sous-dossier). + Retourne un dict { sysname: defaultdict(list) } + """ + result = {} + for sysname in sorted(os.listdir(systems_dir)): + syspath = systems_dir / sysname + if not syspath.is_dir() or sysname.lower() == "_defaults": + continue + + if folder: + scan_dir = syspath / folder + if not scan_dir.exists() or not scan_dir.is_dir(): + continue + scan_dirs = [scan_dir] + else: + scan_dirs = [syspath] + + games = {} + try: + for subdir in scan_dirs: + for fname in os.listdir(subdir): + if (subdir / fname).is_dir(): + continue + name, ext = os.path.splitext(fname) + ext = ext.lower() + if ext not in EXTENSIONS_CACHE: + continue + ftype = EXTENSIONS_CACHE[ext] + key = name.lower() + if key not in games or ftype == 0x67: + games[key] = (name, ftype) + except PermissionError: + continue + + if not games: + continue + + by_idx = defaultdict(list) + for key in sorted(games.keys()): + _orig, ftype = games[key] + by_idx[bigram_index(key)].append((key, ftype)) + + result[sysname] = by_idx + + return result + + +def _write_cache_binary(data: dict, output_path: Path): + """Ecrit un games_cache.bin avec index bigramme 702 entrees.""" + total_systems = len(data) + total_games = sum(len(gl) for by_idx in data.values() for gl in by_idx.values()) + + if total_systems == 0: + print(tr("cache_no_sys")) + return 0, 0 + + HEADER_SIZE = 4 + total_systems * 36 + data_buf = bytearray() + sys_offsets = {} + + for sysname, by_idx in data.items(): + sys_offsets[sysname] = len(data_buf) + letter_table_pos = len(data_buf) + data_buf += b"\x00" * (NB_IDX * 4) + + idx_offsets = [0] * NB_IDX + for li in range(NB_IDX): + games = by_idx.get(li, []) + if not games: + continue + idx_offsets[li] = HEADER_SIZE + len(data_buf) + for gamename, gtype in games: + name_bytes = gamename.lower().encode("utf-8") + b"\x00" + data_buf += bytes([gtype]) + name_bytes + + for li in range(NB_IDX): + pos = letter_table_pos + li * 4 + data_buf[pos : pos + 4] = struct.pack(" 0: + generated.append((fname, nb_sys, nb_games)) + + print(f"\n[INFO] {len(generated)} cache(s) genere(s) :") + for fname, nb_sys, nb_games in generated: + print(f" {fname} ({nb_sys} systemes, {nb_games} jeux)") + return [ + (output_dir / fname, nb_sys, nb_games) for fname, nb_sys, nb_games in generated + ] + + +def _ask_roms_and_config(): + """Boucle commune : demande roms + sélection systèmes. + Depuis la MAJ Recalbox, la config image est fixe : balise -> racine du système. + Retourne (roms_root, tag_configs, selected_systems) ou (None, None, None).""" + while True: + print(tr("ext_roms_where")) + sep("─") + roms_root = ask_path() + if roms_root is None: + return None, None, None + print(f"{tr('roms_ok')}{roms_root}") + + selected_systems = ask_system_selection(roms_root) + if selected_systems is None: + print(tr("back_roms")) + continue + + tag_configs = ask_extraction_config() # retourne toujours [("logo", "")] + return roms_root, tag_configs, selected_systems + + +def _write_log(log_file, roms_root, grand): + log_file.write(tr("ext_log_header")) + log_file.write(f"{tr('ext_log_source')}{roms_root}\n\n") + log_file.write(tr("ext_log_summary")) + log_file.write(f"{tr('ext_log_games')}{grand['games']}\n") + log_file.write(f"{tr('ext_log_copied')}{grand['copied']}\n") + log_file.write(f"{tr('ext_log_skipped')}{grand['skipped']}\n") + log_file.write(f"{tr('ext_log_missing')}{grand['missing']}\n") + + +def mode_full(sd_dir: Path): + title(tr("mode1_title")) + prepare_sd_card(sd_dir) + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + roms_root, tag_configs, selected_systems = _ask_roms_and_config() + if roms_root is None: + print(tr("back_main")) + return + + sep("─") + print(f"\n{tr('ext_title')}") + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = run_extraction( + roms_root, systems_out, tag_configs, log_file, selected_systems + ) + _write_log(log_file, roms_root, grand) + + sep("─") + if PAUSE.should_stop(): + sep() + print(tr("done")) + print(tr("pause_stopping")) + return + print(f"\n{tr('conv_title')}") + PAUSE.state = PAUSE.RUNNING + if not popup_confirm_before_conversion(systems_out): + return + run_conversion(systems_out) + + sep("─") + if PAUSE.should_stop(): + sep() + print(tr("done")) + print(tr("pause_stopping")) + return + print(f"\n{tr('cache_title')}") + build_cache(systems_out, sd_dir) + + # ── Téléchargement _defaults depuis GitHub ─────────────────────────────── + sep("─") + download_defaults(sd_dir) + + # ── Auto-génération systems_cache.dat ──────────────────────────────────── + sep("─") + print(f"\n{tr('sysc_title')}") + sysc_out = sd_dir / "systems_cache.dat" + build_systems_cache(systems_out, sysc_out) + + sep() + print(tr("done")) + print(f"{tr('done_sd')}{sd_dir}") + print(f"{tr('done_log')}{log_path}") + print(f"\n {tr('done_copy_sd')}") + + +def mode_extract_only(sd_dir: Path): + title(tr("mode2_title")) + prepare_sd_card(sd_dir) + systems_out = sd_dir / "systems" + systems_out.mkdir(parents=True, exist_ok=True) + + roms_root, tag_configs, selected_systems = _ask_roms_and_config() + if roms_root is None: + print(tr("back_main")) + return + + log_path = sd_dir / "images_manquantes.txt" + with open(log_path, "w", encoding="utf-8") as log_file: + grand, _ = run_extraction( + roms_root, systems_out, tag_configs, log_file, selected_systems + ) + _write_log(log_file, roms_root, grand) + + sep() + print(tr("done")) + print(f"{tr('done_extracted')}{systems_out}") + print(f"{tr('done_log2')}{log_path}") + + +def mode_convert_only(sd_dir: Path): + title(tr("mode3_title")) + + if not PIL_AVAILABLE: + print(tr("conv_no_pillow")) + return + + print(tr("conv_src_where")) + sep("─") + src_dir = ask_path() + if src_dir is None: + print(tr("back_main")) + return + print(f"{tr('src_ok')}{src_dir}") + + run_conversion(src_dir) + + sep() + print(tr("done")) + print(f"{tr('done_converted')}{src_dir}") + + +def mode_cache_only(sd_dir: Path): + title(tr("mode4_title")) + + default_systems = sd_dir / "systems" + if default_systems.exists(): + print(f"{tr('cache_sys_detected')}{default_systems}") + if ask_yes_no(tr("cache_sys_use")): + systems_dir = default_systems + else: + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + else: + print(f"{tr('cache_sys_missing')}{sd_dir}") + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + + sd_dir.mkdir(parents=True, exist_ok=True) + generated = build_cache(systems_dir, sd_dir) + + sep() + print(tr("done")) + if generated: + print(tr("done_cache_files")) + for path, nb_sys, nb_games in generated: + print(f" 💾 {path} ({nb_sys} syst., {nb_games} jeux)") + print(f"\n {tr('done_copy_cache')}") + return [path for path, _, _ in generated] if generated else [] + + +# ───────────────────────────────────────────────────────────────────────────── +# TÉLÉCHARGEMENT _defaults DEPUIS GITHUB +# ───────────────────────────────────────────────────────────────────────────── + +GITHUB_API_URL = ( + "https://api.github.com/repos/Jamyz/RetroBoxLED/contents/systems/_defaults" +) +GITHUB_RAW_BASE = ( + "https://raw.githubusercontent.com/Jamyz/RetroBoxLED/main/systems/_defaults" +) + + +def download_defaults( + sd_dir: Path, + progress_cb=None, + listen_keyboard: bool = True, + replace_existing=None, + download_missing=None, +): + """ + Propose de télécharger _defaults/ depuis GitHub. + - Si absent → propose de télécharger + - Si présent → propose de mettre à jour (remplace) + - Dans les deux cas, l'utilisateur peut refuser + """ + import urllib.request + import json + + defaults_dir = sd_dir / "systems" / "_defaults" + exists = defaults_dir.exists() and any(defaults_dir.iterdir()) + + sep("─") + print(f"\n{tr('dl_title')}") + sep("─") + print(f" ↪ defaults_dir = {defaults_dir}") + print(f" ↪ GITHUB_API_URL = {GITHUB_API_URL}") + print(f" ↪ GITHUB_RAW_BASE = {GITHUB_RAW_BASE}") + + # Si lancé depuis la GUI (listen_keyboard=False), on évite ask_yes_no() + # et on s'appuie sur replace_existing/download_missing fournis. + if exists: + print(tr("dl_exists")) + if replace_existing is None: + if listen_keyboard: + replace_existing = ask_yes_no(tr("dl_ask_update")) + else: + replace_existing = False # safe default: ne pas effacer + if not replace_existing: + print(tr("dl_skip")) + return + print(tr("dl_replacing")) + shutil.rmtree(defaults_dir) + defaults_dir.mkdir(parents=True, exist_ok=True) + else: + print(tr("dl_missing")) + if download_missing is None: + if listen_keyboard: + download_missing = ask_yes_no(tr("dl_ask_download")) + else: + download_missing = False # safe default: ne pas télécharger + if not download_missing: + print(tr("dl_skip")) + return + + # (si on arrive ici: soit on remplace, soit il faut télécharger) + defaults_dir.mkdir(parents=True, exist_ok=True) + + # Récupère la liste des fichiers via l'API GitHub + try: + req = urllib.request.Request( + GITHUB_API_URL, headers={"User-Agent": "recalbox-toolkit"} + ) + with urllib.request.urlopen(req, timeout=15) as resp: + files = json.loads(resp.read().decode("utf-8")) + except Exception as e: + print(tr("dl_fail_api")) + print(f" {e}") + return + + # Filtre uniquement les fichiers png/gif + media_files = [ + f + for f in files + if f.get("type") == "file" + and Path(f["name"]).suffix.lower() in (".png", ".gif") + ] + + total = len(media_files) + print(tr("dl_starting")) + done = 0 + + PAUSE.start(listen_keyboard=listen_keyboard) + try: + for i, f in enumerate(media_files, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + fname = f["name"] + if progress_cb is not None: + progress_cb("download_defaults", i, total, fname) + + raw_url = f"{GITHUB_RAW_BASE}/{urllib.request.quote(fname)}" + dst = defaults_dir / fname + try: + urllib.request.urlretrieve(raw_url, dst) + done += 1 + print(tr("dl_file_ok")(fname, i, total)) + except Exception as e: + print(tr("dl_file_err")(fname, e)) + finally: + PAUSE.stop() + + print(tr("dl_done")(done)) + + +# ───────────────────────────────────────────────────────────────────────────── +# BUILD SYSTEMS CACHE (systems_cache.dat pour l'ESP32) +# ───────────────────────────────────────────────────────────────────────────── + + +def build_systems_cache( + systems_dir: Path, + output_path: Path, + progress_cb=None, +): + """ + Génère systems_cache.dat au format attendu par l'ESP32 : + g nes + p snes + g neogeo + Scanne systems_dir/_defaults/ — un fichier par système (gif prioritaire). + """ + defaults_dir = systems_dir / "_defaults" + if not defaults_dir.exists(): + print(tr("sysc_no_defaults")) + return 0 + + # Collecte tous les noms de systèmes présents dans _defaults/ + entries = {} + for f in defaults_dir.iterdir(): + if f.is_file() and f.suffix.lower() in (".gif", ".png"): + stem = f.stem.lower() + ftype = "g" if f.suffix.lower() == ".gif" else "p" + # gif prioritaire sur png + if stem not in entries or ftype == "g": + entries[stem] = (f.stem, ftype) + + count = len(entries) + print(tr("sysc_found")(count)) + + stems = sorted(entries.keys()) + total = len(stems) + with open(output_path, "w", encoding="utf-8", newline="\n") as out: + for idx, stem in enumerate(stems, 1): + PAUSE.wait_if_paused() + if PAUSE.should_stop() or PAUSE.should_skip(): + break + + name, ftype = entries[stem] + + # LENT : plus de 800 PNG OU GIF (mais pas les deux) + # -> XOR : (png_over ^ gif_over) + system_dir = systems_dir / name + + def count_ext_over(base: Path, ext_lower: str, limit: int) -> bool: + # Retourne True dès qu'on dépasse "limit" + count = 0 + for _root, _dirs, files in os.walk(base): + for fn in files: + if fn.lower().endswith(ext_lower): + count += 1 + if count > limit: + return True + return False + + png_over = False + gif_over = False + if system_dir.exists() and system_dir.is_dir(): + png_over = count_ext_over(system_dir, ".png", 800) + gif_over = count_ext_over(system_dir, ".gif", 800) + + slow_flag = "L" if (png_over or gif_over) else "N" + out.write(f"{ftype} {name} {slow_flag}\n") + print(tr("sysc_line")(ftype, name)) + + if progress_cb is not None: + progress_cb("systems_cache", idx, total, stem) + + return count + + +def mode_systems_cache(sd_dir: Path): + """Mode 5 : Génère systems_cache.dat depuis sd_card/systems/_defaults/""" + title(tr("sysc_title")) + + default_systems = sd_dir / "systems" + if default_systems.exists(): + print(f"{tr('cache_sys_detected')}{default_systems}") + if ask_yes_no(tr("cache_sys_use")): + systems_dir = default_systems + else: + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + else: + print(f"{tr('cache_sys_missing')}{sd_dir}") + print(tr("cache_sys_where")) + sep("─") + systems_dir = ask_path() + if systems_dir is None: + print(tr("back_main")) + return + + sd_dir.mkdir(parents=True, exist_ok=True) + output_path = sd_dir / "systems_cache.dat" + + count = build_systems_cache(systems_dir, output_path) + + sep() + print(tr("done")) + if count > 0: + print(tr("sysc_done")(count, output_path)) + print(f" 💾 {output_path}") + print(f"\n {tr('sysc_copy')}") + print(tr("sysc_hint")) + return [output_path] + return [] + + +# ───────────────────────────────────────────────────────────────────────────── +# MODE 6 — COPIE RAPIDE SUR CARTE SD (Windows / robocopy) +# ───────────────────────────────────────────────────────────────────────────── + + +def _list_removable_drives(): + """ + Liste les lecteurs amovibles sur Windows via WMI (wmic). + Retourne une liste de tuples (lettre, label, taille_lisible). + """ + import subprocess + + drives = [] + try: + out = subprocess.check_output( + [ + "wmic", + "logicaldisk", + "where", + "drivetype=2", + "get", + "DeviceID,VolumeName,Size", + "/format:csv", + ], + text=True, + stderr=subprocess.DEVNULL, + ) + for line in out.splitlines(): + line = line.strip() + if not line or line.startswith("Node"): + continue + parts = line.split(",") + if len(parts) < 4: + continue + _, device, size_str, label = parts[0], parts[1], parts[2], parts[3] + letter = device.strip() + label = label.strip() or "NO LABEL" + try: + size_gb = int(size_str.strip()) / (1024**3) + size_s = f"{size_gb:.1f} GB" + except Exception: + size_s = "? GB" + if letter: + drives.append((letter, label, size_s)) + except Exception: + pass + return drives + + +def _robocopy(src: Path, dst: str, overwrite: bool): + """ + Lance robocopy avec /MT:32 pour une copie rapide. + overwrite=True → /IS /IT (écrase les fichiers identiques aussi) + overwrite=False → /XC /XN /XO (ignore fichiers plus récents/identiques/anciens) + """ + import subprocess + + src_str = str(src) + flags = ["/E", "/MT:32", "/NFL", "/NJH", "/NP"] + if overwrite: + flags += ["/IS", "/IT"] + else: + flags += ["/XC", "/XN", "/XO"] + + cmd = ["robocopy", src_str, dst] + flags + print(tr("flash_copy_start")(src_str, dst)) + + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + i = 0 + while proc.poll() is None: + print( + f"\r {spinner[i % len(spinner)]} Copie en cours...", end="", flush=True + ) + i += 1 + time.sleep(0.1) + print("\r" + " " * 30 + "\r", end="") # efface la ligne spinner + + if proc.returncode < 4: + print(tr("flash_copy_ok")) + else: + print(tr("flash_copy_err")(proc.returncode)) + + +def _flash_files(files: list, sd_dir: Path): + """Copie une liste de fichiers précis sur une carte SD (Windows uniquement).""" + import subprocess + + if sys.platform != "win32": + print(tr("flash_no_win")) + return + + if not files: + print(tr("flash_no_sdcard")) + return + + while True: + print(tr("flash_drives_title")) + drives = _list_removable_drives() + + if not drives: + print(tr("flash_no_drives")) + input(tr("press_enter")) + return + + for i, (letter, label, size) in enumerate(drives, 1): + print(f" {i} → {letter}\\ [{label}] {size}") + print(f" 0 → {tr('back')}") + print() + + raw = input(tr("flash_drive_choice")).strip() + if raw == "0": + print(tr("back_main")) + return + if not raw.isdigit() or not (1 <= int(raw) <= len(drives)): + print(tr("flash_drive_warn")) + continue + + letter, label, size = drives[int(raw) - 1] + dst_drive = Path(f"{letter}\\") + print(tr("flash_drive_sel")(f"{letter}\\ [{label}]", size)) + print() + + for src in files: + dst = dst_drive / src.name + print(f"📋 {src.name} → {dst}") + try: + import shutil as _shutil + + _shutil.copy2(src, dst) + print(f" ✅ OK") + except Exception as e: + print(f" ❌ {e}") + print() + print(tr("flash_copy_ok")) + return + + +def mode_flash_sd(sd_dir: Path): + """Mode 6 : Copie rapide du contenu sd_card/ sur une carte SD via robocopy.""" + title(tr("flash_title")) + + # ── Vérification Windows ────────────────────────────────────────────────── + if sys.platform != "win32": + print(tr("flash_no_win")) + return + + # ── Vérification sd_card/ non vide ─────────────────────────────────────── + if not sd_dir.exists() or not any(sd_dir.iterdir()): + print(tr("flash_no_sdcard")) + return + + while True: + # ── Liste des lecteurs amovibles ────────────────────────────────────── + print(tr("flash_drives_title")) + drives = _list_removable_drives() + + if not drives: + print(tr("flash_no_drives")) + input(tr("press_enter")) + return + + for i, (letter, label, size) in enumerate(drives, 1): + print(f" {i} → {letter}\\ [{label}] {size}") + print(f" 0 → {tr('back')}") + print() + + raw = input(tr("flash_drive_choice")).strip() + if raw == "0": + print(tr("back_main")) + return + if not raw.isdigit() or not (1 <= int(raw) <= len(drives)): + print(tr("flash_drive_warn")) + continue + + letter, label, size = drives[int(raw) - 1] + dst_drive = f"{letter}\\" + print(tr("flash_drive_sel")(f"{letter}\\ [{label}]", size)) + + # ── Choix du mode de copie ──────────────────────────────────────────── + print(tr("flash_mode_title")) + print(f" 1 → {tr('flash_mode_opt2')}") + print(f" 2 → {tr('flash_mode_opt3')}") + print(f" 0 → {tr('back')}") + print() + + while True: + raw2 = input(tr("flash_mode_choice")).strip() + if raw2 == "0": + break + if raw2 in ("1", "2"): + break + print(tr("flash_mode_warn")) + + if raw2 == "0": + continue # retour au choix du lecteur + + # ── Copie robocopy ──────────────────────────────────────────────────── + overwrite = raw2 == "1" + _robocopy(sd_dir, dst_drive, overwrite) + + sep() + print(tr("done")) + return + + +# ───────────────────────────────────────────────────────────────────────────── +# SÉLECTION DE LANGUE +# ───────────────────────────────────────────────────────────────────────────── + + +def select_language(): + global T + print() + print("╔══════════════════════════════════════════════════════════════════╗") + print("║ RetroBoxLED Toolkit for Recalbox ║") + print("╚══════════════════════════════════════════════════════════════════╝") + print() + print(" Choisissez votre langue / Choose your language / Elija su idioma") + print() + print(" 1 → English") + print(" 2 → Français") + print(" 3 → Español") + print() + while True: + raw = input(" > ").strip() + if raw == "1": + T = TRANSLATIONS["en"] + return + if raw == "2": + T = TRANSLATIONS["fr"] + return + if raw == "3": + T = TRANSLATIONS["es"] + return + print(" ⚠️ 1 / 2 / 3\n") + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + + +def main(): + select_language() + ensure_dependencies() + + script_dir = Path(__file__).parent + sd_dir = get_sd_card_dir(script_dir) + + print() + sep() + print(f" {tr('main_title')}") + sep() + print() + print(f" {tr('main_prompt')}") + print() + print(f" 1 → {tr('main_opt1')}") + print(f" 2 → {tr('main_opt2')}") + print(f" 3 → {tr('main_opt3')}") + print(f" 4 → {tr('main_opt4')}") + print(f" 5 → {tr('main_opt5')}") + print(f" 6 → {tr('main_opt6')}") + print(f" 7 → {tr('main_opt7')}") + print(f" 0 → {tr('main_opt_quit')}") + print() + + while True: + choix = input(tr("main_choice")).strip() + if choix in ("0", "1", "2", "3", "4", "5", "6", "7"): + break + print(tr("main_warn")) + + print() + + while True: + if choix == "0": + break + generated_files = [] # fichiers ciblés pour modes 4 et 5 + if choix == "1": + mode_full(sd_dir) + elif choix == "2": + mode_extract_only(sd_dir) + elif choix == "3": + mode_convert_only(sd_dir) + elif choix == "4": + generated_files = mode_cache_only(sd_dir) or [] + elif choix == "5": + generated_files = mode_systems_cache(sd_dir) or [] + elif choix == "6": + mode_flash_sd(sd_dir) + elif choix == "7": + import RetroBoxLED_gui as gui + + gui.run_gui(sys.modules[__name__], sd_dir) + return + + # ── Menu de fin ─────────────────────────────────────────────────────── + print() + sep("─") + print(f" {tr('after_menu')}") + print() + print(f" 1 → {tr('after_opt1')}") + if choix in ("4", "5"): + if generated_files: + print(f" 2 → {tr('after_opt_files')}") + else: + print(f" 2 → {tr('after_opt6')}") + print(f" 0 → {tr('press_enter').replace('...', '')}") + print() + + valid = ["0", "1"] + if choix not in ("4", "5") or generated_files: + valid.append("2") + + while True: + raw = input(" > ").strip() + if raw in valid: + break + print(tr("main_warn")) + + if raw == "0": + break + elif raw == "1": + # Retour au menu principal + print() + sep() + print(f" {tr('main_title')}") + sep() + print() + print(f" {tr('main_prompt')}") + print() + print(f" 1 → {tr('main_opt1')}") + print(f" 2 → {tr('main_opt2')}") + print(f" 3 → {tr('main_opt3')}") + print(f" 4 → {tr('main_opt4')}") + print(f" 5 → {tr('main_opt5')}") + print(f" 6 → {tr('main_opt6')}") + print(f" 7 → {tr('main_opt7')}") + print(f" 0 → {tr('main_opt_quit')}") + print() + while True: + choix = input(tr("main_choice")).strip() + if choix in ("0", "1", "2", "3", "4", "5", "6", "7"): + break + print(tr("main_warn")) + print() + if choix == "0": + break + elif raw == "2": + if choix in ("4", "5") and generated_files: + _flash_files(generated_files, sd_dir) + # Menu de fin après copie ciblée + print() + sep("─") + print(f" {tr('after_menu')}") + print() + print(f" 1 → {tr('after_opt1')}") + print(f" 0 → {tr('press_enter').replace('...', '')}") + print() + while True: + raw2 = input(" > ").strip() + if raw2 in ("0", "1"): + break + print(tr("main_warn")) + if raw2 == "0": + choix = "0" + elif raw2 == "1": + print() + sep() + print(f" {tr('main_title')}") + sep() + print() + print(f" {tr('main_prompt')}") + print() + print(f" 1 → {tr('main_opt1')}") + print(f" 2 → {tr('main_opt2')}") + print(f" 3 → {tr('main_opt3')}") + print(f" 4 → {tr('main_opt4')}") + print(f" 5 → {tr('main_opt5')}") + print(f" 6 → {tr('main_opt6')}") + print(f" 7 → {tr('main_opt7')}") + print(f" 0 → {tr('main_opt_quit')}") + print() + while True: + choix = input(tr("main_choice")).strip() + if choix in ("0", "1", "2", "3", "4", "5", "6"): + break + print(tr("main_warn")) + print() + if choix == "0": + choix = "0" + else: + choix = "6" + + +if __name__ == "__main__": + import RetroBoxLED_gui as gui + from pathlib import Path + + script_dir = Path(__file__).parent + sd_dir = get_sd_card_dir(script_dir) + + toolkit_module = sys.modules[__name__] + app = gui.RetroBoxLEDGui(toolkit_module, sd_dir) + app.run() diff --git a/tools/RecalboxMirrorSDV2.bat b/tools/RecalboxMirrorSDV2.bat new file mode 100644 index 0000000..576f8e5 --- /dev/null +++ b/tools/RecalboxMirrorSDV2.bat @@ -0,0 +1,172 @@ +@ECHO off +setlocal enabledelayedexpansion +chcp 1252 >nul +title Recalbox Mirror SD +color 0A + +set "config=%~dp0config.txt" + +:main +cls +echo. +echo ╔════════════════════════════════════════╗ +echo ║ RECALBOX MIRROR SD v2.0 ║ +echo ╚════════════════════════════════════════╝ +echo. + +if exist "%config%" ( + set i=0 + for /f "usebackq delims=" %%a in ("%config%") do ( + set /a i+=1 + if !i!==1 set "saved_roms=%%a" + if !i!==2 set "saved_user=%%a" + ) + echo [√] Config: !saved_user!@!saved_roms! +) else ( + echo [!] Aucune configuration sauvegardee +) +echo. +echo [1] Lancer le mirror +echo [2] Verifier la carte SD +echo [3] Configurer les identifiants NAS +echo [4] Quitter +echo. +set /p choix=" Choix: " + +if "%choix%"=="1" goto :mirror +if "%choix%"=="2" goto :show +if "%choix%"=="3" goto :config +if "%choix%"=="4" goto :quit +goto :main + +:config +cls +echo. +echo ═══ CONFIGURATION NAS ═══ +echo. +set /p roms=" Chemin reseau (ex: \\192.168.1.10\roms): " +set /p user=" Utilisateur: " +set /p pass=" Mot de passe: " +echo. +echo Test de connexion... +net use "%roms%" /user:%user% %pass% >nul 2>&1 +if errorlevel 1 ( + color 0C + echo [X] ECHEC - Verifiez vos identifiants + net use "%roms%" /delete >nul 2>&1 + pause + color 0A + goto :main +) +net use "%roms%" /delete >nul 2>&1 +( +echo %roms% +echo %user% +echo %pass% +) > "%config%" +color 0B +echo [√] Configuration sauvegardee avec succes +timeout /t 2 >nul +color 0A +goto :main + +:mirror +cls +echo. +echo ═══ MIRROR SD ═══ +echo. +set /p drive=" Lettre SD (ex: E): " +set "drive=%drive::=%" + +if not exist "%drive%:\" ( + color 0C + echo [X] Lecteur %drive%: introuvable + pause + color 0A + goto :main +) + +if not exist "%config%" ( + color 0E + echo [!] Configuration manquante - Utilisez option [3] + pause + color 0A + goto :main +) + +set i=0 +for /f "usebackq delims=" %%a in ("%config%") do ( + set /a i+=1 + if !i!==1 set "roms=%%a" + if !i!==2 set "user=%%a" + if !i!==3 set "pass=%%a" +) + +echo Connexion a %roms%... +net use "%roms%" /user:%user% %pass% >nul 2>&1 +if errorlevel 1 ( + color 0C + echo [X] Connexion impossible - Verifiez la config + pause + color 0A + goto :main +) + +set "sys=%drive%:\systems" +echo [√] Connecte +echo. +echo Creation des dossiers... + +mkdir "%sys%\default" 2>nul +mkdir "%sys%\favorites" 2>nul +echo [+] default +echo [+] favorites + +set c=2 +for /f %%d in ('dir "%roms%" /ad /b 2^>nul') do ( + mkdir "%sys%\%%d" 2>nul + echo [+] %%d + set /a c+=1 +) + +net use "%roms%" /delete >nul 2>&1 +echo. +color 0B +echo ════════════════════════════════════ +echo TERMINE - !c! dossiers crees +echo ════════════════════════════════════ +timeout /t 3 >nul +color 0A +goto :main + +:show +cls +echo. +echo ═══ VERIFICATION SD ═══ +echo. +set /p drive=" Lettre SD (ex: E): " +set "drive=%drive::=%" + +if not exist "%drive%:\systems" ( + color 0E + echo [!] Dossier systems introuvable sur %drive%: + pause + color 0A + goto :main +) + +echo. +echo Contenu de %drive%:\systems: +echo ──────────────────────────────── +for /f %%d in ('dir "%drive%:\systems" /ad /b /on') do echo • %%d +echo ──────────────────────────────── +echo. +pause +goto :main + +:quit +cls +echo. +echo Au revoir! +timeout /t 1 >nul +exit \ No newline at end of file