diff --git a/Readme.md b/Readme.md index effb622..7936936 100644 --- a/Readme.md +++ b/Readme.md @@ -40,6 +40,6 @@ python -m ProyectoGlobal 4. Scraping -5. Juego de los camellos / autos de choque / etc. (aplicar resolución de sincronización para evitar problemas de interbloqueos) +5. ~~Juego de los camellos~~ / autos de choque / etc. (aplicar resolución de sincronización para evitar problemas de interbloqueos) -6. Música de fondo (reproducción de mp3 o midi) \ No newline at end of file +6. ~~Música de fondo (reproducción de mp3 o midi)~~ \ No newline at end of file diff --git a/logica/T2/alarm.py b/logica/T2/alarm.py index e69de29..dd08091 100644 --- a/logica/T2/alarm.py +++ b/logica/T2/alarm.py @@ -0,0 +1,131 @@ +# Módulo: logica/T2/alarm.py + +import tkinter as tk +from datetime import datetime +import os +import sys + +try: + from logica.T2.musicReproductor import MusicReproductor +except ImportError: + class MusicReproductor: + def __init__(self, *args, **kwargs): pass + + def ajustar_volumen(self, valor): pass + + def cargar_y_reproducir(self, url): print(f"🎶 SIMULANDO PLAY: {url}") + + def detener(self): print("🔇 SIMULANDO STOP") + + +class AlarmManager: + """ + Gestiona la creación, seguimiento y disparo de múltiples alarmas (temporizadores). + """ + + def __init__(self, root, trigger_callback): + self.root = root + self.active_alarms = {} + self.next_id = 1 + self.trigger_callback = trigger_callback + + self.alarm_sound_player = MusicReproductor() + + self.ALARM_SOUND_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'res', 'alarm.mp3') + + # === MODIFICACIÓN CLAVE: Acepta total_seconds en lugar de minutes === + def set_alarm(self, total_seconds): + """ + Programa una nueva alarma para sonar después de un número de segundos. + + :param total_seconds: Número de segundos hasta que suena la alarma. + :return: ID único de la alarma. + """ + if total_seconds <= 0: + raise ValueError("El tiempo de alarma debe ser positivo.") + + alarm_id = self.next_id + self.next_id += 1 + + ms_delay = total_seconds * 1000 + ms_delay_int = int(ms_delay) + + tiempo_disparo_ts = datetime.now().timestamp() + total_seconds + tiempo_disparo_dt = datetime.fromtimestamp(tiempo_disparo_ts) + + name = f"⏰ {tiempo_disparo_dt.strftime('%H:%M:%S')}" + + alarm_data = { + 'nombre': name, + 'total_seconds': total_seconds, # Almacenamos el tiempo total en segundos + 'tiempo_creacion': datetime.now(), + 'tiempo_disparo': tiempo_disparo_ts, + 'after_id': None, + 'sonada': False + } + + after_id = self.root.after(ms_delay_int, lambda: self._trigger_alarm(alarm_id)) + + alarm_data['after_id'] = after_id + self.active_alarms[alarm_id] = alarm_data + + print(f"🔔 Alarma '{name}' ({alarm_id}) programada para sonar en {total_seconds} segundos.") + return alarm_id + + def _trigger_alarm(self, alarm_id): + # ... (código sin cambios aquí) ... + if alarm_id in self.active_alarms: + alarm = self.active_alarms[alarm_id] + + print(f"🚨 ¡ALARMA! La alarma '{alarm['nombre']}' ({alarm_id}) ha sonado.") + + alarm['sonada'] = True + + self.alarm_sound_player.cargar_y_reproducir(self.ALARM_SOUND_PATH) + + self.trigger_callback(alarm['nombre'], alarm_id) + + def stop_alarm_sound(self): + self.alarm_sound_player.detener() + + def cancel_alarm(self, alarm_id): + # ... (código sin cambios aquí) ... + if alarm_id in self.active_alarms: + alarm = self.active_alarms[alarm_id] + + if not alarm['sonada'] and alarm['after_id']: + self.root.after_cancel(alarm['after_id']) + print(f"❌ Alarma '{alarm['nombre']}' ({alarm_id}) cancelada.") + + del self.active_alarms[alarm_id] + return True + return False + + def get_active_alarms(self): + """ + Retorna una lista de las alarmas activas, incluyendo el tiempo restante. + """ + alarms_to_show = [] + current_time = datetime.now().timestamp() + + for alarm_id, alarm in list(self.active_alarms.items()): + if not alarm['sonada']: + time_diff = alarm['tiempo_disparo'] - current_time + + if time_diff > 0: + remaining_sec = int(time_diff) + + hours = remaining_sec // 3600 + minutes = (remaining_sec % 3600) // 60 + seconds = remaining_sec % 60 + + remaining_str = f"{hours:02d}h:{minutes:02d}m:{seconds:02d}s" + + alarms_to_show.append({ + 'id': alarm_id, + 'nombre': alarm['nombre'], + 'restante': remaining_str, + 'total_seconds': alarm['total_seconds'] # Usar total_seconds + }) + + return sorted(alarms_to_show, key=lambda x: x['restante']) \ No newline at end of file diff --git a/logica/T2/musicReproductor.py b/logica/T2/musicReproductor.py index c319e0c..16ab7c5 100644 --- a/logica/T2/musicReproductor.py +++ b/logica/T2/musicReproductor.py @@ -1,97 +1,77 @@ # Módulo: logica/T2/musicReproductor.py import vlc -import threading import os -import sys -# --- BLOQUE DE CÓDIGO OPCIONAL PARA SOLUCIÓN DE ERRORES DE RUTA DE VLC --- -# Si al ejecutar el programa obtienes un error de "ImportError: DLL load failed" -# o similar con 'vlc', DESCOMENTA el siguiente bloque y AJUSTA la ruta de vlc_path. -# Esto ayuda a que Python encuentre las librerías principales de VLC. -# -# if sys.platform.startswith('win'): -# # RUTA DE EJEMPLO PARA WINDOWS (AJUSTA según tu instalación) -# vlc_path = r"C:\Program Files\VideoLAN\VLC" -# if vlc_path not in os.environ.get('PATH', ''): -# os.environ['PATH'] += os.pathsep + vlc_path -# ------------------------------------------------------------------------- - class MusicReproductor: """ - Gestiona la reproducción de streams de radio usando la librería python-vlc. + Clase para gestionar la reproducción de audio utilizando la librería python-vlc. + Se asegura de que el objeto 'player' de VLC sea liberado y recreado correctamente + para poder manejar múltiples eventos de alarma o cambiar de stream de radio sin fallos. """ - def __init__(self, initial_volume=50.0): - """Inicializa la instancia de VLC y el reproductor.""" - - # Instancia de VLC y objeto Reproductor + def __init__(self, initial_volume=50): + # 1. Crear la instancia de VLC, que debe ser única por aplicación. self.instance = vlc.Instance() + + # 2. Creamos el player inicial. self.player = self.instance.media_player_new() - self.current_media = None - self.is_playing = False - # Configurar volumen inicial + self.volumen = initial_volume self.ajustar_volumen(initial_volume) - print(f"🎵 [VLC] Reproductor inicializado. Volumen: {self.player.audio_get_volume()}") + print(f"🎵 [VLC] Reproductor inicializado. Volumen: {self.volumen}") - def ajustar_volumen(self, valor_porcentual): + def ajustar_volumen(self, valor): + """Ajusta el volumen del reproductor.""" + self.volumen = int(valor) + if self.player: + self.player.audio_set_volume(self.volumen) + + def cargar_y_reproducir(self, url): """ - Ajusta el volumen del reproductor (0 a 100). + Carga el archivo o stream y lo reproduce. + + **Corrección:** Recrea self.player si fue liberado (release) por el método detener(). """ - volumen_int = int(max(0, min(100, valor_porcentual))) - self.player.audio_set_volume(volumen_int) - # No imprimimos el volumen aquí para evitar saturar la consola con cada movimiento del Scale + print(f"🔄 [VLC] Intentando cargar y reproducir: {url}") - def cargar_y_reproducir(self, url_stream): - """ - Carga una nueva URL de stream y comienza la reproducción. - """ - if not url_stream: - print("❌ [VLC] URL del stream vacía.") - return + # 1. Recrear el reproductor si fue liberado. + if not self.player: + self.player = self.instance.media_player_new() + self.ajustar_volumen(self.volumen) # Restaurar volumen - print(f"🔄 [VLC] Intentando cargar y reproducir: {url_stream}") + # 2. Detener la reproducción actual de forma segura antes de cargar una nueva media. + # Esto previene el AttributeError, ya que self.player ahora está garantizado que no es None. + if self.player: + self.player.stop() - self.player.stop() + # 3. Cargar y reproducir la nueva media. + media = self.instance.media_new(url) + self.player.set_media(media) - self.current_media = self.instance.media_new(url_stream) - self.player.set_media(self.current_media) - - self.player.play() - self.is_playing = True - print("✅ [VLC] Reproducción iniciada.") - - def reproducir(self): - """ - Reanuda la reproducción si está pausada. - """ - if self.player.get_state() == vlc.State.Paused: - self.player.play() - self.is_playing = True - print("▶️ [VLC] Reproducción reanudada.") + if self.player.play() == 0: + print("✅ [VLC] Reproducción iniciada.") else: - print("ℹ️ [VLC] Ya está reproduciéndose o esperando un stream.") - - def pausar(self): - """ - Pausa la reproducción. - """ - if self.player.get_state() == vlc.State.Playing: - self.player.pause() - self.is_playing = False - print("⏸️ [VLC] Reproducción pausada.") - else: - print("ℹ️ [VLC] No se puede pausar, el reproductor no está en estado de reproducción.") + print("❌ [VLC] Error al intentar iniciar la reproducción.") def detener(self): """ - Detiene la reproducción y libera los recursos. Crucial al cerrar la aplicación. + Detiene la reproducción, libera los recursos del player y lo establece a None. + Esto es crucial para que el sistema de audio no se quede bloqueado por VLC. """ if self.player: self.player.stop() - # Limpiar referencias para asegurar que VLC se libere correctamente - del self.player - del self.instance - print("⏹️ [VLC] Reproductor detenido y recursos liberados.") \ No newline at end of file + self.player.release() + self.player = None # <--- Obliga a recrear el player en la próxima llamada a cargar_y_reproducir + print("⏹️ [VLC] Reproductor detenido y recursos liberados.") + + def pausar(self): + """Pausa la reproducción.""" + if self.player and self.player.is_playing(): + self.player.pause() + + def reproducir(self): + """Reanuda la reproducción.""" + if self.player: + self.player.play() \ No newline at end of file diff --git a/res/alarm.mp3 b/res/alarm.mp3 new file mode 100644 index 0000000..ae952ea Binary files /dev/null and b/res/alarm.mp3 differ diff --git a/res/radios.json b/res/radios.json index 2ada0f6..46a8f27 100644 --- a/res/radios.json +++ b/res/radios.json @@ -16,5 +16,23 @@ "url_stream": "https://stream.serviciospararadios.es/listen/activa_fm/activafm-tunein.mp3", "pais": "ES", "genero": null + }, + { + "nombre": "Cope Denia", + "url_stream": "https://denia-copesedes-rrcast.flumotion.com/copesedes/denia-low.mp3", + "pais": "ES", + "genero": "Noticias" + }, + { + "nombre": "KPOO", + "url_stream": "http://amber.streamguys.com:5220/xstream", + "pais": null, + "genero": null + }, + { + "nombre": "Cope Valencia", + "url_stream": "https://valencia-copesedes-rrcast.flumotion.com/copesedes/valencia-low.mp3", + "pais": "ES", + "genero": "Noticias" } ] \ No newline at end of file diff --git a/vista/panel_central.py b/vista/panel_central.py index a7895f2..78e589f 100644 --- a/vista/panel_central.py +++ b/vista/panel_central.py @@ -3,13 +3,16 @@ import random import tkinter as tk from tkinter import ttk +from tkinter import messagebox import json +from datetime import datetime import os -import sys # Necesario para el bloque opcional de VLC +import sys # --- LÓGICA DE T1 (MONITOR DE RECURSOS) --- from logica.T1.trafficMeter import iniciar_monitor_red from logica.T1.graficos import crear_grafico_recursos, actualizar_historial_datos +from logica.T1.textEditor import cargar_contenido_res_notes, guardar_contenido_res_notes # <--- AÑADIDO from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg @@ -21,26 +24,28 @@ from logica.T2.carreraCamellos import ( ) # --- LÓGICA DE T3 (REPRODUCTOR DE MÚSICA) --- -# Se necesita el bloque opcional aquí, ya que el import 'vlc' está en este módulo +# Bloque para manejar la dependencia de VLC try: from logica.T2.musicReproductor import MusicReproductor except ImportError: - # Bloque OPCIONAL si falla el import de MusicReproductor por problemas de VLC en el entorno - print("⚠️ Error al importar MusicReproductor. Asegúrate de tener 'python-vlc' instalado y VLC en tu sistema.") + print("⚠️ Error al importar MusicReproductor. Usando simulador.") class MusicReproductor: def __init__(self, *args, **kwargs): pass - def ajustar_volumen(self, valor): print(f"Volumen (Simulado): {valor}") + def ajustar_volumen(self, valor): pass - def cargar_y_reproducir(self, url): print(f"Reproduciendo (Simulado): {url}") + def cargar_y_reproducir(self, url): print(f"🎶 SIMULANDO PLAY: {url}") - def reproducir(self): print("Reproducir (Simulado)") + def reproducir(self): pass - def pausar(self): print("Pausar (Simulado)") + def pausar(self, *args): pass - def detener(self): print("Detener (Simulado)") + def detener(self): pass + +# 🟢 LÓGICA DE T4 (ALARMAS) +from logica.T2.alarm import AlarmManager # --- IMPORTACIÓN UNIVERSAL DE CONSTANTES --- from vista.config import * @@ -48,12 +53,11 @@ from vista.config import * class PanelCentral(ttk.Frame): """Contiene el Notebook (subpestañas), el panel de Notas y el panel de Chat, - y gestiona directamente la lógica de control de T1, T2 y T3.""" + y gestiona directamente la lógica de control de T1, T2, T3 y T4.""" INTERVALO_ACTUALIZACION_MS = INTERVALO_RECURSOS_MS INTERVALO_CARRERA_MS = 200 - # ✅ CORRECCIÓN DE RUTA NOMBRE_FICHERO_RADIOS = "res/radios.json" def __init__(self, parent, root, *args, **kwargs): @@ -62,6 +66,9 @@ class PanelCentral(ttk.Frame): self.after_id = None self.after_carrera_id = None + self.after_alarm_id = None + + # T2 self.camellos = [] self.progreso_labels = {} self.frame_carrera_controles = None @@ -69,37 +76,56 @@ class PanelCentral(ttk.Frame): self.carrera_info_label = None self.carrera_estado_label = None - # 1. INICIALIZACIÓN DE VARIABLES (T1) + # T1 self.net_monitor = iniciar_monitor_red() self.figure = Figure(figsize=(5, 4), dpi=100) self.canvas = None - # 2. INICIALIZACIÓN DE VARIABLES Y LÓGICA DE RADIO (T3) + # T3 (Radios) self.emisoras_cargadas = self.cargar_emisoras() self.radio_seleccionada = tk.StringVar(value="---") self.volumen_var = tk.DoubleVar(value=50.0) self.reproductor = MusicReproductor(initial_volume=self.volumen_var.get()) - # 3. CONFIGURACIÓN DEL LAYOUT + # 🟢 T4 (Alarmas) - Inicialización de variables UI. + self.alarm_manager = None + self.alarm_list_frame = None + self.scrollable_frame = None + self.alarm_hours_entry = None + self.alarm_minutes_entry = None + self.alarm_seconds_entry = None + + # 📄 Tareas (res/notes) + self.notes_text_editor = None # <--- AÑADIDO + + # 2. CONFIGURACIÓN DEL LAYOUT self.grid_columnconfigure(0, weight=3) self.grid_columnconfigure(1, weight=1) self.grid_rowconfigure(0, weight=1) - self.crear_area_principal_y_notas() + self.crear_area_principal() # <--- FUNCIÓN SIMPLIFICADA self.crear_panel_chat_y_alumnos() - # 4. INICIO DE CICLOS DE ACTUALIZACIÓN + # Inicializar el AlarmManager solo después de que show_alarm_popup esté definido. + self.inicializar_alarmas() + + # 3. INICIO DE CICLOS DE ACTUALIZACIÓN self.iniciar_actualizacion_automatica() self.iniciar_actualizacion_carrera() + self.iniciar_actualizacion_alarmas() + + def inicializar_alarmas(self): + """Inicializa AlarmManager, pasándole el método de callback show_alarm_popup.""" + self.alarm_manager = AlarmManager(self.root, self.show_alarm_popup) # ------------------------------------------------------------- # 📻 LÓGICA Y VISTA DE T3 (REPRODUCTOR DE RADIOS) + # ... (El código de cargar_emisoras, crear_interfaz_radios, seleccionar_radio, controlar_reproduccion, cambiar_volumen no tiene cambios) # ------------------------------------------------------------- def cargar_emisoras(self): """Carga la lista de emisoras desde el archivo radios.json.""" try: - # ✅ La ruta ahora apunta correctamente al subdirectorio 'res' with open(self.NOMBRE_FICHERO_RADIOS, 'r', encoding='utf-8') as f: return json.load(f) except FileNotFoundError: @@ -152,11 +178,9 @@ class PanelCentral(ttk.Frame): emisora = self.emisoras_cargadas[indice] url = emisora['url_stream'] - # 1. Actualizar la interfaz self.radio_seleccionada.set(emisora['nombre']) self.url_seleccionada_label.config(text=url) - # 2. Llamar a la lógica del reproductor self.reproductor.cargar_y_reproducir(url) def controlar_reproduccion(self, accion): @@ -168,43 +192,308 @@ class PanelCentral(ttk.Frame): def cambiar_volumen(self, valor): """ - Llama al método de control de volumen del reproductor, - asegurando que el valor sea un entero para evitar saltos del Scale. + Ajusta el volumen, asegurando que el valor sea un entero para estabilizar el Scale. """ - # ✅ CORRECCIÓN DE LA BARRA DE VOLUMEN - # 1. Convertir el valor de punto flotante a entero valor_entero = int(float(valor)) - # 2. Actualizar la variable de control con el valor entero. self.volumen_var.set(valor_entero) - - # 3. Llamar a la lógica del reproductor. self.reproductor.ajustar_volumen(valor_entero) + # ------------------------------------------------------------- + # 🔔 LÓGICA Y VISTA DE T4 (ALARMAS / TEMPORIZADORES) + # ------------------------------------------------------------- + + def crear_interfaz_alarmas(self, parent_frame): + """Crea la interfaz para programar y visualizar alarmas (H:M:S).""" + + frame = ttk.Frame(parent_frame, padding=10, style='TFrame') + frame.pack(expand=True, fill="both") + + ttk.Label(frame, text="Programar Nuevo Temporizador (H:M:S)", font=FUENTE_NEGOCIOS).pack(pady=(0, 10)) + + # --- Controles de Nueva Alarma (H:M:S) --- + frame_input = ttk.Frame(frame, style='TFrame') + frame_input.pack(fill='x', pady=5) + + # Horas + ttk.Label(frame_input, text="Horas:").pack(side='left', padx=(0, 2)) + self.alarm_hours_entry = ttk.Entry(frame_input, width=3) + self.alarm_hours_entry.pack(side='left', padx=(0, 10)) + self.alarm_hours_entry.insert(0, "0") + + # Minutos + ttk.Label(frame_input, text="Minutos:").pack(side='left', padx=(0, 2)) + self.alarm_minutes_entry = ttk.Entry(frame_input, width=3) + self.alarm_minutes_entry.pack(side='left', padx=(0, 10)) + self.alarm_minutes_entry.insert(0, "1") + + # Segundos + ttk.Label(frame_input, text="Segundos:").pack(side='left', padx=(0, 2)) + self.alarm_seconds_entry = ttk.Entry(frame_input, width=3) + self.alarm_seconds_entry.pack(side='left', padx=(0, 15)) + self.alarm_seconds_entry.insert(0, "0") + + ttk.Button(frame_input, text="➕ Crear Alarma", command=self.manejar_nueva_alarma, + style='Action.TButton').pack(side='left') + + ttk.Separator(frame, orient='horizontal').pack(fill='x', pady=15) + + # --- Listado de Alarmas Activas --- + ttk.Label(frame, text="Alarmas Activas (Tiempo Restante)", font=FUENTE_NEGOCIOS).pack(pady=(0, 5)) + + self.alarm_list_frame = ttk.Frame(frame) + self.alarm_list_frame.pack(fill="both", expand=True) + + canvas = tk.Canvas(self.alarm_list_frame, borderwidth=0, background=COLOR_BLANCO) + vscroll = ttk.Scrollbar(self.alarm_list_frame, orient="vertical", command=canvas.yview) + + self.scrollable_frame = ttk.Frame(canvas) + + self.scrollable_frame.bind( + "", + lambda e: canvas.configure( + scrollregion=canvas.bbox("all") + ) + ) + + canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=vscroll.set) + + canvas.pack(side="left", fill="both", expand=True) + vscroll.pack(side="right", fill="y") + + def manejar_nueva_alarma(self): + """Captura los datos del formulario (H:M:S), los convierte a segundos y llama al AlarmManager.""" + try: + # 1. Leer los valores (usando 'or 0' para manejar campos vacíos como 0) + hours = int(self.alarm_hours_entry.get() or 0) + minutes = int(self.alarm_minutes_entry.get() or 0) + seconds = int(self.alarm_seconds_entry.get() or 0) + + # 2. Calcular el total de segundos + total_seconds = (hours * 3600) + (minutes * 60) + seconds + + if total_seconds <= 0: + print("⚠️ El tiempo de alarma debe ser un número positivo (H:M:S > 0).") + return + + # 3. Llamar al AlarmManager con el total de segundos + self.alarm_manager.set_alarm(total_seconds) + + # 4. Limpiar y preparar para la siguiente alarma (Default: 1 minuto) + self.alarm_hours_entry.delete(0, tk.END) + self.alarm_hours_entry.insert(0, "0") + self.alarm_minutes_entry.delete(0, tk.END) + self.alarm_minutes_entry.insert(0, "1") + self.alarm_seconds_entry.delete(0, tk.END) + self.alarm_seconds_entry.insert(0, "0") + + self.actualizar_lista_alarmas() + + except ValueError: + print("⚠️ Por favor, introduce números enteros válidos para el tiempo.") + except AttributeError: + print("⚠️ Error: AlarmManager no inicializado.") + + def manejar_cancelar_alarma(self, alarm_id): + """Cancela la alarma usando su ID.""" + if self.alarm_manager.cancel_alarm(alarm_id): + self.actualizar_lista_alarmas() + + def actualizar_lista_alarmas(self): + """Actualiza la visualización de las alarmas activas con botones de cancelación individuales.""" + if not self.scrollable_frame: + self.after_alarm_id = self.after(1000, self.actualizar_lista_alarmas) + return + + for widget in self.scrollable_frame.winfo_children(): + widget.destroy() + + active_alarms = self.alarm_manager.get_active_alarms() + + if not active_alarms: + ttk.Label(self.scrollable_frame, text="--- No hay alarmas activas ---", font=('Consolas', 10), + foreground=COLOR_TEXTO).pack(padx=10, pady=10) + + for alarm in active_alarms: + self.add_alarm_row(self.scrollable_frame, alarm) + + self.after_alarm_id = self.after(1000, self.actualizar_lista_alarmas) + + def add_alarm_row(self, parent, alarm_data): + """Añade una fila con la info de la alarma y su botón de cancelación.""" + row_frame = ttk.Frame(parent, padding=5, style='Note.TFrame') + row_frame.pack(fill='x', padx=5, pady=2) + + # Convertir total_seconds a formato Hh:Mm:Ss para la visualización del tiempo total + total_s = alarm_data['total_seconds'] + h = total_s // 3600 + m = (total_s % 3600) // 60 + s = total_s % 60 + total_time_str = f"{h:02d}h:{m:02d}m:{s:02d}s" + + # Info de la alarma + info_text = (f"[ID{alarm_data['id']}] {alarm_data['restante']} -> {alarm_data['nombre']} " + f"({total_time_str} total)") + ttk.Label(row_frame, text=info_text, font=('Consolas', 10), style='Note.TLabel').pack(side='left', fill='x', + expand=True) + + # Botón de Cancelación Individual + ttk.Button(row_frame, text="❌ Cancelar", style='Danger.TButton', width=10, + command=lambda id=alarm_data['id']: self.manejar_cancelar_alarma(id)).pack(side='right') + + def iniciar_actualizacion_alarmas(self): + """Inicia el ciclo de actualización de la lista de alarmas.""" + if self.alarm_manager: + self.after_alarm_id = self.after(0, self.actualizar_lista_alarmas) + + def detener_actualizacion_alarmas(self): + """Detiene el ciclo de actualización de la lista de alarmas.""" + if hasattr(self, 'after_alarm_id') and self.after_alarm_id: + self.after_cancel(self.after_alarm_id) + self.after_alarm_id = None + print("Ciclo de actualización de alarmas detenido.") + + # ------------------------------------------------------------- + # 🔔 POPUP DE ALARMA (Notificación) + # ------------------------------------------------------------- + + def show_alarm_popup(self, alarm_name, alarm_id): + """Muestra una ventana Toplevel sin barra de título ni botón de cierre.""" + + # 1. Crear la ventana popup + popup = tk.Toplevel(self.root) + popup.title("🚨 ¡ALARMA!") + popup.geometry("350x150") + popup.resizable(False, False) + + # ✅ Eliminar la barra de título y los botones (incluido el de cierre 'X') + popup.overrideredirect(True) + + # Hacer que el popup sea modal (siempre encima) + popup.transient(self.root) + popup.grab_set() + + # 2. Centrar la ventana + self.root.update_idletasks() + width = popup.winfo_width() + height = popup.winfo_height() + x = (self.root.winfo_screenwidth() // 2) - (width // 2) + y = (self.root.winfo_screenheight() // 2) - (height // 2) + popup.geometry(f'{width}x{height}+{x}+{y}') + + # 3. Función para cerrar y detener la música + def close_and_stop(event=None): + """Función para cerrar el popup y detener el sonido.""" + self.alarm_manager.stop_alarm_sound() + self.alarm_manager.cancel_alarm(alarm_id) + popup.destroy() + self.actualizar_lista_alarmas() + + # 4. Contenido + frame = ttk.Frame(popup, padding=20, relief='solid', borderwidth=2) + frame.pack(expand=True, fill="both") + + ttk.Label(frame, text="¡El Temporizador ha Terminado!", font=FUENTE_TITULO, foreground=COLOR_ACCION).pack( + pady=5) + ttk.Label(frame, text=f"Hora de Disparo: {alarm_name}", font=FUENTE_NEGOCIOS).pack(pady=5) + ttk.Label(frame, text="Haz clic para cerrar.", font=('Arial', 9, 'italic'), foreground=COLOR_TEXTO).pack(pady=0) + + # 5. Configurar el cierre + # La forma de cerrar es mediante un clic en la ventana. + popup.bind("", close_and_stop) + frame.bind("", close_and_stop) + + # 6. Esperar a que se cierre para continuar la ejecución del hilo principal de Tkinter + self.root.wait_window(popup) + + # ------------------------------------------------------------- + # 📄 LÓGICA Y VISTA DE TAREAS (Editor de Notas) + # ------------------------------------------------------------- + + def crear_interfaz_tareas(self, parent_frame): + """Crea el editor de texto simple para el archivo res/notes dentro de la pestaña Tareas.""" + + frame = ttk.Frame(parent_frame, padding=15, style='TFrame') + frame.pack(expand=True, fill="both") + + ttk.Label(frame, text="Editor de Notas ", font=FUENTE_TITULO).pack(pady=(0, 10), anchor="w") + ttk.Label(frame, text="Use este panel para tomar notas rápidas sobre la ejecución de tareas.", + font=FUENTE_NEGOCIOS).pack(pady=(0, 15), anchor="w") + + # 1. Widget de texto + self.notes_text_editor = tk.Text( + frame, + height=20, + wrap="word", + bg=COLOR_BLANCO, + relief="solid", + borderwidth=1, + font=FUENTE_MONO + ) + self.notes_text_editor.pack(fill="both", expand=True, pady=(0, 10)) + + # 2. Botones de Cargar y Guardar + frame_botones = ttk.Frame(frame) + frame_botones.pack(fill="x", pady=(5, 0)) + + ttk.Button(frame_botones, text="Guardar Cambios", command=self.guardar_res_notes, style='Action.TButton').pack( + side=tk.RIGHT) + ttk.Button(frame_botones, text="Cargar Archivo", command=self.cargar_res_notes, style='Action.TButton').pack( + side=tk.LEFT) + + self.cargar_res_notes(initial_load=True) # Carga inicial al crear la interfaz + + def cargar_res_notes(self, initial_load=False): + """Carga el contenido de res/notes al editor de texto.""" + if not self.notes_text_editor: return + + contenido = cargar_contenido_res_notes() + + self.notes_text_editor.delete("1.0", tk.END) + + if "Error al cargar:" in contenido: + self.notes_text_editor.insert(tk.END, contenido) + else: + if initial_load and not contenido.strip(): + self.notes_text_editor.insert(tk.END, "# Escriba aquí sus notas (res/notes)") + else: + self.notes_text_editor.insert(tk.END, contenido) + + if initial_load: + print("Cargado 'res/notes' en la pestaña Tareas.") + + def guardar_res_notes(self): + """Guarda el contenido del editor de texto en res/notes.""" + if not self.notes_text_editor: return + + contenido = self.notes_text_editor.get("1.0", tk.END) + + success, message = guardar_contenido_res_notes(contenido) + + if success: + messagebox.showinfo("✅ Guardado", "Notas guardadas exitosamente.") + print(message) + else: + messagebox.showerror("❌ Error al Guardar", message) + print(f"FALLO AL GUARDAR: {message}") + # ------------------------------------------------------------- # 📦 ESTRUCTURA PRINCIPAL DEL PANEL # ------------------------------------------------------------- - def crear_area_principal_y_notas(self): - """Crea el contenedor de las subpestañas y el panel de notas.""" + def crear_area_principal(self): + """Crea el contenedor de las subpestañas.""" frame_izquierdo = ttk.Frame(self, style='TFrame') frame_izquierdo.grid(row=0, column=0, sticky="nsew") - frame_izquierdo.grid_rowconfigure(0, weight=4) - frame_izquierdo.grid_rowconfigure(1, weight=1) + frame_izquierdo.grid_rowconfigure(0, weight=1) frame_izquierdo.grid_columnconfigure(0, weight=1) self.crear_notebook_pestañas(frame_izquierdo) - panel_notas = ttk.Frame(frame_izquierdo, style='Note.TFrame') - panel_notas.grid(row=1, column=0, sticky="nsew", pady=(5, 0)) - - ttk.Label(panel_notas, text="Panel para notas informativas y mensajes sobre la ejecución de los hilos.", - style='Note.TLabel', anchor="nw", justify=tk.LEFT, padding=10, font=FUENTE_NOTA).pack( - expand=True, fill="both") - def crear_notebook_pestañas(self, parent_frame): - """Crea las pestañas internas para las tareas (T1, Carrera, Radios).""" + """Crea las pestañas internas para las tareas (T1, Carrera, Radios, Tareas, Alarmas, etc.).""" sub_notebook = ttk.Notebook(parent_frame) sub_notebook.grid(row=0, column=0, sticky="nsew") @@ -230,8 +519,16 @@ class PanelCentral(ttk.Frame): elif sub_tab_text == "Radios": self.crear_interfaz_radios(frame) - # ------------------------------------------------------------- + elif sub_tab_text == "Tareas": + self.crear_interfaz_tareas(frame) # <--- Llamada a la nueva interfaz + + elif sub_tab_text == "Alarmas": + self.crear_interfaz_alarmas(frame) + + # ------------------------------------------------------------- + # 🐪 LÓGICA DE T2 (CARRERA DE CAMELLOS) + # ... (El código de carreraCamellos no cambia) # ------------------------------------------------------------- def crear_interfaz_carrera(self, parent_frame): @@ -414,7 +711,7 @@ class PanelCentral(ttk.Frame): self.after_id = self.after(0, self.actualizar_recursos) def detener_actualizacion_automatica(self): - """Detiene el ciclo de actualización periódica y el hilo de red (T1) y T3.""" + """Detiene el ciclo de actualización periódica y los hilos/tareas.""" if self.after_id: self.after_cancel(self.after_id) self.after_id = None @@ -426,8 +723,8 @@ class PanelCentral(ttk.Frame): print("Hilo de TrafficMeter detenido.") self.detener_actualizacion_carrera() + self.detener_actualizacion_alarmas() - # Detener el reproductor al cerrar la aplicación if self.reproductor: self.reproductor.detener() @@ -471,7 +768,7 @@ class PanelCentral(ttk.Frame): ttk.Button(frame_alumno, text="↻", width=3, style='Action.TButton').grid(row=0, column=1, rowspan=2, padx=5, sticky="ne") - # --- FILA 8: Reproductor Música --- + # --- FILA 8: Reproductor Música (T3) --- musica_frame = ttk.LabelFrame(panel_chat, text="Reproductor Música", padding=10, style='TFrame') musica_frame.grid(row=8, column=0, sticky="ew", pady=(15, 0)) @@ -498,6 +795,4 @@ class PanelCentral(ttk.Frame): ttk.Label(musica_frame, textvariable=self.volumen_var, style='TLabel').grid(row=2, column=2, sticky="w") - musica_frame.grid_columnconfigure(1, weight=1) - - panel_chat.grid_rowconfigure(8, weight=0) \ No newline at end of file + musica_frame.grid_columnconfigure(1, weight=1) \ No newline at end of file diff --git a/vista/panel_lateral.py b/vista/panel_lateral.py index f41fbcf..e01a97f 100644 --- a/vista/panel_lateral.py +++ b/vista/panel_lateral.py @@ -6,7 +6,8 @@ from tkinter import messagebox from logica.controlador import accion_placeholder from logica.T1.backup import accion_backup_t1 from logica.T1.runVScode import abrir_vscode -from logica.T1.textEditor import cargar_contenido_res_notes, guardar_contenido_res_notes +# NO necesitamos importar cargar/guardar notas aquí, ya que la lógica se mueve al Panel Central +# from logica.T1.textEditor import cargar_contenido_res_notes, guardar_contenido_res_notes from logica.T1.openBrowser import navegar_a_url # --- IMPORTACIÓN DE CONSTANTES DESDE vista/config.py --- @@ -14,14 +15,14 @@ from vista.config import * class PanelLateral(ttk.Frame): - """Contiene el menú de botones, entradas para las tareas y el editor simple para res/notes.""" + """Contiene el menú de botones y entradas para las tareas.""" # Usamos la constante importada ANCHO_CARACTERES_FIJO = ANCHO_CARACTERES_PANEL_LATERAL def __init__(self, parent, central_panel=None, *args, **kwargs): super().__init__(parent, *args, **kwargs) - # La referencia al PanelCentral es esencial para iniciar la carrera + # La referencia al PanelCentral es esencial para iniciar la carrera y acceder a sus métodos self.central_panel = central_panel self.configurar_estilos_locales(parent) @@ -40,15 +41,11 @@ class PanelLateral(ttk.Frame): self.crear_seccion(self, titulo="", acciones=acciones_extraccion) # 3. Área de Aplicaciones - - # --- CAMBIO CLAVE: CONEXIÓN DE APP2 --- - - # Definimos el comando para App2 usando el método que llama a PanelCentral app2_comando = self.manejar_inicio_carrera_t2 acciones_aplicaciones = [ ("Visual Code", abrir_vscode), - ("App2 (Carrera 🏁)", app2_comando), # <--- CONEXIÓN REALIZADA + ("App2 (Carrera 🏁)", app2_comando), ("App3", lambda: accion_placeholder("App3")) ] self.crear_seccion(self, titulo="Aplicaciones", acciones=acciones_aplicaciones) @@ -60,19 +57,21 @@ class PanelLateral(ttk.Frame): self.crear_seccion(self, titulo="Procesos batch", acciones=acciones_batch) # 5. Espacio expandible + # Ahora este marco se expandirá para ocupar todo el espacio restante. tk.Frame(self, height=1).pack(expand=True, fill="both") - # 6. Panel de Notas - self.crear_editor_res_notes() + # 6. Panel de Notas - ELIMINADO: Se moverá a la pestaña Tareas del Panel Central. + # self.crear_editor_res_notes() # <--- LÍNEA ELIMINADA + + # --- MÉTODOS DE LÓGICA / CONTROL --- - # --- NUEVO MÉTODO PARA MANEJAR LA CARRERA (App2) --- def manejar_inicio_carrera_t2(self): """ Llama al método 'manejar_inicio_carrera' del Panel Central. """ if self.central_panel: print("Botón App2 presionado. Iniciando Carrera de Camellos en Panel Central...") - # Aquí es donde se llama a la función expuesta por PanelCentral + # Llamada a la función expuesta por PanelCentral self.central_panel.manejar_inicio_carrera() # Opcional: Cambiar automáticamente a la pestaña Resultados @@ -84,7 +83,6 @@ class PanelLateral(ttk.Frame): else: messagebox.showerror("Error", "El Panel Central no está inicializado.") - # --- MÉTODOS EXISTENTES --- def manejar_navegacion(self, event=None): """ Obtiene el texto de la entrada superior y llama a la función de navegación. @@ -93,67 +91,10 @@ class PanelLateral(ttk.Frame): if navegar_a_url(url): self.entrada_superior.delete(0, tk.END) - def crear_editor_res_notes(self): - """Crea el editor de texto simple para el archivo res/notes.""" - - ttk.Label(self, text="Editor Simple (res/notes)", font=FUENTE_NEGOCIOS).pack(fill="x", pady=(10, 0), - padx=5) - - frame_editor = ttk.Frame(self, padding=5) - frame_editor.pack(fill="x", padx=5, pady=(0, 10)) - - # 1. Widget de texto - self.notes_text_editor = tk.Text( - frame_editor, - height=8, - width=self.ANCHO_CARACTERES_FIJO, - wrap="word", - bg=COLOR_BLANCO, - relief="solid", - borderwidth=1, - font=FUENTE_MONO - ) - self.notes_text_editor.pack(fill="x", expand=False) - - # 2. Botones de Cargar y Guardar - frame_botones = ttk.Frame(frame_editor) - frame_botones.pack(fill="x", pady=(5, 0)) - - ttk.Button(frame_botones, text="Guardar", command=self.guardar_res_notes, style='SmallAction.TButton').pack( - side=tk.RIGHT) - ttk.Button(frame_botones, text="Cargar", command=self.cargar_res_notes, style='SmallAction.TButton').pack( - side=tk.LEFT) - - self.cargar_res_notes(initial_load=True) - - def cargar_res_notes(self, initial_load=False): - """Carga el contenido de res/notes al editor de texto lateral.""" - contenido = cargar_contenido_res_notes() - - self.notes_text_editor.delete("1.0", tk.END) - - if "Error al cargar:" in contenido: - self.notes_text_editor.insert(tk.END, contenido) - else: - if initial_load and not contenido.strip(): - self.notes_text_editor.insert(tk.END, "# Escriba aquí sus notas (res/notes)") - else: - self.notes_text_editor.insert(tk.END, contenido) - - print("Cargado 'res/notes' en el editor lateral.") - - def guardar_res_notes(self): - """Guarda el contenido del editor de texto lateral en res/notes.""" - contenido = self.notes_text_editor.get("1.0", tk.END) - - success, message = guardar_contenido_res_notes(contenido) - - if success: - messagebox.showinfo("✅ Guardado", "Notas guardadas exitosamente.") - print(message) - else: - messagebox.showerror("❌ Error al Guardar", message) - print(f"FALLO AL GUARDAR: {message}") + # --- MÉTODOS DE NOTAS ELIMINADOS (Se moverán a PanelCentral) --- + # def crear_editor_res_notes(self): ... + # def cargar_res_notes(self, initial_load=False): ... + # def guardar_res_notes(self): ... def manejar_extraccion_datos(self): """