Commit inicial: G Radio Remoto (gr-client)
Cliente remoto GTK4 para G Radio Player: se conecta al servidor TCP embebido en radio-player para controlar el reproductor y editar programación (pautaje, parrilla, botonera, buscador, reportes) desde otra máquina de la LAN.
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Rust build artifacts
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock.bak
|
||||||
|
|
||||||
|
# Empaquetado Debian (staging + salidas)
|
||||||
|
gr-client_*_amd64/
|
||||||
|
gr-client_*_arm64/
|
||||||
|
gr-client_*_amd64.deb
|
||||||
|
gr-client_*_arm64.deb
|
||||||
|
GR-client-v*.zip
|
||||||
|
sha256sum.txt
|
||||||
|
|
||||||
|
# Runtime logs
|
||||||
|
nohup.out
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Notas internas / sesiones de desarrollo (no deben subirse)
|
||||||
|
docs/sesion-*.txt
|
||||||
|
gr-client-server
|
||||||
|
|
||||||
|
# Editor / OS junk
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.claude/
|
||||||
|
.~lock.*#
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog — gr-client (escritorio)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.7] — 2026-05-19
|
||||||
|
|
||||||
|
### Novedades
|
||||||
|
|
||||||
|
- **Multidiomas (es / en / pt) — Fase 1**: la UI acepta el idioma del sistema operativo y soporta español, inglés y portugués. Nuevo selector "Idioma:" al final de la barra de conexión con opción "Automático". Persiste como `locale: "es|en|pt"` en `~/.gradio/client_config.json` (campo opcional; omitido cuando es automático). Al cambiarlo aparece un diálogo "Reinicie para aplicar".
|
||||||
|
- Strings traducidos: título de ventana, pestañas (Player / Búsqueda / Parrilla / Botonera / Pautaje / Comercios / Reportes), labels del panel de conexión (Servidor, Token), estados de conexión, botones Conectar/Desconectar/Cancelar.
|
||||||
|
|
||||||
|
### Pendiente (Fase 2)
|
||||||
|
|
||||||
|
- Migrar strings de los paneles individuales (PanelPlayer, PanelPautaje, PanelParrilla, PanelComercial, etc.) que aún están en español.
|
||||||
|
- Convertir `Error { mensaje }` del servidor a códigos traducibles localmente.
|
||||||
Generated
+2037
File diff suppressed because it is too large
Load Diff
+35
@@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "radio-player-client"
|
||||||
|
version = "0.1.7"
|
||||||
|
edition = "2021"
|
||||||
|
description = "G Radio Client — cliente remoto para G Radio Player"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "gr-client"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# GTK4 GUI
|
||||||
|
gtk4 = { version = "0.9", features = ["v4_6", "v4_8"] }
|
||||||
|
glib = "0.20"
|
||||||
|
glib-macros = "0.20"
|
||||||
|
gdk4 = "0.9"
|
||||||
|
gdk-pixbuf = "0.20"
|
||||||
|
cairo-rs = { version = "0.20", features = ["png"] }
|
||||||
|
|
||||||
|
# Red / async
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# Serialización
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Utilidades
|
||||||
|
chrono = { version = "0.4", features = ["clock"] }
|
||||||
|
dirs = "5"
|
||||||
|
anyhow = "1"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
native-tls = "0.2"
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
# G Radio Client — Manual de usuario
|
||||||
|
|
||||||
|
**Versión 0.1.2**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Índice
|
||||||
|
|
||||||
|
1. [Introducción](#1-introducción)
|
||||||
|
2. [Requisitos del sistema](#2-requisitos-del-sistema)
|
||||||
|
3. [Instalación](#3-instalación)
|
||||||
|
4. [Configuración del servidor](#4-configuración-del-servidor)
|
||||||
|
5. [Conexión al servidor](#5-conexión-al-servidor)
|
||||||
|
6. [Interfaz principal](#6-interfaz-principal)
|
||||||
|
7. [Panel Player](#7-panel-player)
|
||||||
|
8. [Buscador de audio](#8-buscador-de-audio)
|
||||||
|
9. [Panel Pautaje](#9-panel-pautaje)
|
||||||
|
10. [Panel Parrilla](#10-panel-parrilla)
|
||||||
|
11. [Panel Botonera](#11-panel-botonera)
|
||||||
|
12. [Panel Reportes](#12-panel-reportes)
|
||||||
|
13. [Conexión por Internet (relay)](#13-conexión-por-internet-relay)
|
||||||
|
14. [Solución de problemas](#14-solución-de-problemas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introducción
|
||||||
|
|
||||||
|
**G Radio Client** (`gr-client`) es una aplicación GTK4 que permite controlar remotamente un servidor **G Radio Player** desde cualquier computadora de la red local o desde internet a través del sistema de relay.
|
||||||
|
|
||||||
|
Desde el cliente se puede:
|
||||||
|
|
||||||
|
- Controlar la reproducción en tiempo real (play, pausa, stop, skip, volumen)
|
||||||
|
- Gestionar la playlist del servidor
|
||||||
|
- Editar la parrilla musical (programación horaria)
|
||||||
|
- Editar el pautaje comercial y de eventos
|
||||||
|
- Buscar archivos de audio en el servidor y arrastrarlos a la programación
|
||||||
|
- Operar la botonera de sonidos de acceso rápido
|
||||||
|
- Consultar reportes de audios emitidos
|
||||||
|
|
||||||
|
Toda la operación se realiza sobre los archivos del servidor; el cliente no almacena contenido propio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Requisitos del sistema
|
||||||
|
|
||||||
|
### Equipo cliente
|
||||||
|
|
||||||
|
| Componente | Versión mínima |
|
||||||
|
|---|---|
|
||||||
|
| GTK4 | 4.6 |
|
||||||
|
| glib | 2.72 |
|
||||||
|
| Debian / Ubuntu | 22.04 LTS o posterior |
|
||||||
|
| Red | LAN o internet con acceso al servidor |
|
||||||
|
|
||||||
|
### Equipo servidor
|
||||||
|
|
||||||
|
El servidor debe tener instalado y en ejecución **G Radio Player** (`gradio-player`) versión **0.2.9** o posterior, con el servidor TCP activo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Instalación
|
||||||
|
|
||||||
|
### Desde el paquete .deb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dpkg -i gr-client_0.1.2_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
O con resolución automática de dependencias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./gr-client_0.1.2_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecutar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gr-client
|
||||||
|
```
|
||||||
|
|
||||||
|
O bien desde el menú de aplicaciones: buscar **G Radio Client**.
|
||||||
|
|
||||||
|
### Desinstalar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove gr-client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Configuración del servidor
|
||||||
|
|
||||||
|
Antes de conectar el cliente es necesario configurar correctamente el servidor G Radio Player para que acepte conexiones remotas.
|
||||||
|
|
||||||
|
### 4.1 Archivo de configuración del servidor
|
||||||
|
|
||||||
|
El servidor lee su configuración desde:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.gradio/data/tmp/gradio.config
|
||||||
|
```
|
||||||
|
|
||||||
|
El archivo tiene una línea por campo, en este orden:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nombre de la radio
|
||||||
|
Dispositivo de audio (ej: default)
|
||||||
|
G Radio
|
||||||
|
Duración del crossfade en segundos (ej: 3.0)
|
||||||
|
Puerto del servidor gr-client (ej: 7777)
|
||||||
|
Token de autenticación (vacío = sin contraseña)
|
||||||
|
Relay habilitado (0 = no, 1 = sí)
|
||||||
|
ID del relay (8 dígitos, ej: 12345678)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo de archivo `gradio.config`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Radio Ejemplo FM
|
||||||
|
default
|
||||||
|
G Radio
|
||||||
|
3.0
|
||||||
|
7777
|
||||||
|
mi-token-seguro
|
||||||
|
0
|
||||||
|
00000000
|
||||||
|
```
|
||||||
|
|
||||||
|
> La primera vez que se ejecuta `gradio.sh`, el archivo se crea automáticamente con valores por defecto. Se puede editar con cualquier editor de texto mientras el servidor está detenido, o desde la ventana de configuración dentro de la aplicación.
|
||||||
|
|
||||||
|
### 4.2 Puerto del servidor
|
||||||
|
|
||||||
|
Por defecto el servidor escucha en el puerto **7777** TCP. Para cambiarlo, editar la línea 5 del archivo `gradio.config`.
|
||||||
|
|
||||||
|
Asegurarse de que el puerto esté abierto en el firewall del equipo servidor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar que el servidor está escuchando
|
||||||
|
ss -tlnp | grep 7777
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Token de autenticación
|
||||||
|
|
||||||
|
El token es una contraseña compartida entre el servidor y todos los clientes. Puede ser cualquier cadena de texto.
|
||||||
|
|
||||||
|
- Si la línea del token está **vacía**, el servidor acepta cualquier conexión sin contraseña.
|
||||||
|
- Si tiene contenido, el cliente **debe** ingresar el mismo token para conectarse.
|
||||||
|
|
||||||
|
**Recomendación:** usar siempre token cuando el servidor sea accesible desde internet.
|
||||||
|
|
||||||
|
### 4.4 Habilitación del relay (acceso remoto por internet)
|
||||||
|
|
||||||
|
Para acceder al servidor desde fuera de la red local se usa el sistema de relay. Ver la sección [13. Conexión por Internet (relay)](#13-conexión-por-internet-relay).
|
||||||
|
|
||||||
|
Para habilitarlo, poner `1` en la línea 7 y un ID de 8 dígitos en la línea 8 del archivo `gradio.config`. El servidor se conectará automáticamente al relay al iniciarse.
|
||||||
|
|
||||||
|
### 4.5 Verificar que el servidor está activo
|
||||||
|
|
||||||
|
Al ejecutar `gradio.sh` el servidor TCP se inicia automáticamente junto con el reproductor. Para verificarlo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver procesos activos
|
||||||
|
ps aux | grep radio-player
|
||||||
|
|
||||||
|
# Ver puerto abierto
|
||||||
|
ss -tlnp | grep 7777
|
||||||
|
```
|
||||||
|
|
||||||
|
El cliente mostrará la versión del servidor al conectarse correctamente (ej: `● Conectado v0.2.9`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Conexión al servidor
|
||||||
|
|
||||||
|
### 5.1 Panel de conexión
|
||||||
|
|
||||||
|
En la parte superior de la ventana principal se encuentra el panel de conexión:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Servidor: [192.168.1.100] Puerto: [7777] Token: [••••••••] │
|
||||||
|
│ [ ] Internet ID: [________] [Conectar] ● Desconectado │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Conexión en red local (LAN)
|
||||||
|
|
||||||
|
1. Dejar el checkbox **Internet** desactivado.
|
||||||
|
2. Ingresar la **IP o hostname** del equipo servidor (ej: `192.168.1.100` o `servidor-radio`).
|
||||||
|
3. Ingresar el **puerto** (por defecto: `7777`).
|
||||||
|
4. Ingresar el **token** si el servidor lo tiene configurado. Dejar vacío si no hay contraseña.
|
||||||
|
5. Hacer clic en **Conectar**.
|
||||||
|
|
||||||
|
El indicador de estado cambia a:
|
||||||
|
|
||||||
|
| Estado | Indicador |
|
||||||
|
|---|---|
|
||||||
|
| Desconectado | ● Desconectado (gris) |
|
||||||
|
| Conectando | ⟳ Conectando… |
|
||||||
|
| Conectado | ● Conectado v0.2.9 (verde) |
|
||||||
|
| Error | ✗ Error: descripción (rojo) |
|
||||||
|
|
||||||
|
### 5.3 Verificar la conexión
|
||||||
|
|
||||||
|
Al conectarse correctamente:
|
||||||
|
|
||||||
|
- El indicador muestra verde con la versión del servidor.
|
||||||
|
- El panel Player se actualiza con el estado actual de reproducción.
|
||||||
|
- La playlist del servidor se carga automáticamente.
|
||||||
|
- Todos los controles se habilitan.
|
||||||
|
|
||||||
|
### 5.4 Reconexión automática
|
||||||
|
|
||||||
|
Si la conexión se interrumpe, el cliente intenta reconectarse automáticamente con retroceso exponencial (primero cada 5 segundos, luego aumenta hasta 120 segundos).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Interfaz principal
|
||||||
|
|
||||||
|
La ventana principal (1400 × 750 px) se divide en dos áreas:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Panel de conexión (parte superior) │
|
||||||
|
├──────────────────────┬──────────────────────────────────────────┤
|
||||||
|
│ │ ┌──────────────────────────────────┐ │
|
||||||
|
│ │ │ ▶ Player │ │
|
||||||
|
│ Buscador │ ├──────────────────────────────────┤ │
|
||||||
|
│ (siempre visible) │ │ 📅 Pautaje │ │
|
||||||
|
│ │ ├──────────────────────────────────┤ │
|
||||||
|
│ - Campo de búsqueda│ │ 🎵 Parrilla │ │
|
||||||
|
│ - Botón buscar │ ├──────────────────────────────────┤ │
|
||||||
|
│ - Lista resultados │ │ 🎛 Botonera │ │
|
||||||
|
│ - Drag-and-drop │ ├──────────────────────────────────┤ │
|
||||||
|
│ │ │ 📊 Reportes │ │
|
||||||
|
│ │ └──────────────────────────────────┘ │
|
||||||
|
└──────────────────────┴──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
El **Buscador** permanece visible en todo momento a la izquierda. Los paneles funcionales se acceden por pestañas en el área derecha.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Panel Player
|
||||||
|
|
||||||
|
El panel Player permite controlar la reproducción en tiempo real sobre el servidor.
|
||||||
|
|
||||||
|
### 7.1 Información del track actual
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ♪ Nombre del track actual │
|
||||||
|
│ 01:23 ──────────────●──────────────── 03:45 │
|
||||||
|
│ Volumen: ────────────●──── 85 │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Título:** nombre del archivo en reproducción (sin extensión).
|
||||||
|
- **Barra de progreso:** posición actual / duración total. Solo lectura.
|
||||||
|
- **Slider de volumen:** ajusta el volumen de música (0–100). El volumen se aplica en el servidor.
|
||||||
|
|
||||||
|
### 7.2 Controles de reproducción
|
||||||
|
|
||||||
|
| Botón | Función |
|
||||||
|
|---|---|
|
||||||
|
| ▶ Play | Inicia la reproducción |
|
||||||
|
| ⏸ Pausa | Pausa / reanuda |
|
||||||
|
| ⏹ Stop | Detiene la reproducción |
|
||||||
|
| ⏭ Stop After | Detiene al terminar el track actual |
|
||||||
|
| → Skip | Salta al siguiente track de la playlist |
|
||||||
|
|
||||||
|
Todos los controles están deshabilitados cuando el cliente está desconectado.
|
||||||
|
|
||||||
|
### 7.3 Playlist
|
||||||
|
|
||||||
|
Lista las canciones en cola para reproducción en el servidor.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──┬────┬─────────────────────────────┬──────────┬──────────────┐
|
||||||
|
│ │ # │ Título │ Duración │ Acciones │
|
||||||
|
├──┼────┼─────────────────────────────┼──────────┼──────────────┤
|
||||||
|
│▶ │ 1 │ Canción actual │ 03:45 │ ▶ ↑ ↓ ✗ │
|
||||||
|
│ │ 2 │ Siguiente canción │ 04:12 │ ▶ ↑ ↓ ✗ │
|
||||||
|
│ │ 3 │ Otra canción más │ 02:58 │ ▶ ↑ ↓ ✗ │
|
||||||
|
└──┴────┴─────────────────────────────┴──────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Botón | Función |
|
||||||
|
|---|---|
|
||||||
|
| ▶ | Reproducir este track inmediatamente |
|
||||||
|
| ↑ | Mover una posición arriba |
|
||||||
|
| ↓ | Mover una posición abajo |
|
||||||
|
| ✗ | Eliminar de la playlist |
|
||||||
|
|
||||||
|
### 7.4 Agregar tracks a la playlist
|
||||||
|
|
||||||
|
**Método principal: drag-and-drop desde el Buscador**
|
||||||
|
|
||||||
|
1. Buscar el archivo en el panel Buscador (izquierda).
|
||||||
|
2. Hacer clic y arrastrar el resultado hacia la lista de la playlist.
|
||||||
|
3. Soltar en la posición deseada.
|
||||||
|
|
||||||
|
La duración se obtiene automáticamente del servidor.
|
||||||
|
|
||||||
|
La playlist se actualiza en tiempo real: si otro operador modifica la playlist en el servidor o desde otro cliente, los cambios se reflejan automáticamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Buscador de audio
|
||||||
|
|
||||||
|
El buscador permite localizar archivos de audio almacenados en el servidor y arrastrarlos a cualquier panel.
|
||||||
|
|
||||||
|
### 8.1 Búsqueda
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Buscar: [adele ] 🔍 │
|
||||||
|
│ En: [/home/radio/mus ] │
|
||||||
|
│ ⟳ Índice │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ Nombre │ Duración │
|
||||||
|
│ Rolling.mp3 │ 03:43.000 │
|
||||||
|
│ Someone.mp3 │ 04:52.000 │
|
||||||
|
│ Hello.mp3 │ 04:55.000 │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Buscar:** nombre del archivo (parcial, sin distinguir mayúsculas).
|
||||||
|
- **En:** ruta opcional para limitar la búsqueda a una carpeta específica. Si se deja vacío, busca en todo el servidor.
|
||||||
|
- **Botón 🔍 o Enter:** ejecuta la búsqueda.
|
||||||
|
- **Botón ⟳ Índice:** actualiza la base de datos de búsqueda en el servidor. Necesario si se agregaron archivos nuevos recientemente.
|
||||||
|
|
||||||
|
> La búsqueda usa una base de datos local del servidor (`~/.gradio/locatedb`). Si los resultados no incluyen archivos recientes, hacer clic en **⟳ Índice** para actualizar.
|
||||||
|
|
||||||
|
### 8.2 Menú contextual (clic derecho)
|
||||||
|
|
||||||
|
| Opción | Función |
|
||||||
|
|---|---|
|
||||||
|
| ▶ Reproducir ahora | Envía el archivo al servidor para reproducción inmediata |
|
||||||
|
| + Agregar al playlist | Lo añade al final de la playlist |
|
||||||
|
| 📋 Copiar ruta | Copia la ruta completa al portapapeles |
|
||||||
|
|
||||||
|
### 8.3 Drag-and-drop
|
||||||
|
|
||||||
|
Los archivos del buscador pueden arrastrarse hacia:
|
||||||
|
|
||||||
|
| Destino | Efecto |
|
||||||
|
|---|---|
|
||||||
|
| Playlist (panel Player) | Agrega el track a la playlist en la posición soltada |
|
||||||
|
| Panel Pautaje | Crea una nueva entrada comercial con ese archivo |
|
||||||
|
| Panel Parrilla | Agrega el archivo como item en la hora seleccionada |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Panel Pautaje
|
||||||
|
|
||||||
|
Permite editar la programación de comerciales, eventos y eventos en espera del servidor.
|
||||||
|
|
||||||
|
### 9.1 Tipos de pautaje
|
||||||
|
|
||||||
|
El panel tiene tres pestañas:
|
||||||
|
|
||||||
|
| Pestaña | Carpeta en servidor | Uso |
|
||||||
|
|---|---|---|
|
||||||
|
| Comerciales | `~/.gradio/data/comerciales/` | Publicidad programada |
|
||||||
|
| Eventos | `~/.gradio/data/eventos/` | Eventos instantáneos |
|
||||||
|
| Eventos-Espera | `~/.gradio/data/eventos-espera/` | Eventos en cola |
|
||||||
|
|
||||||
|
### 9.2 Selección de hora y minuto
|
||||||
|
|
||||||
|
```
|
||||||
|
Hora: [10 ▾] Minuto: [30 ▾] [⟳ Cargar]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Hora:** 00 a 23.
|
||||||
|
- **Minuto:** 00, 05, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55.
|
||||||
|
- Hacer clic en **⟳ Cargar** para ver las entradas de esa franja horaria.
|
||||||
|
|
||||||
|
### 9.3 Tabla de entradas
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┬──────────┬──────────┬──────────┐
|
||||||
|
│ Archivo │ Días │ Inicio │ Fin │
|
||||||
|
├──────────────────────────────┼──────────┼──────────┼──────────┤
|
||||||
|
│ /radio/comerciales/spot1.mp3 │ 12345 │ 20250101 │ 20251231 │
|
||||||
|
│ /radio/comerciales/spot2.mp3 │ 1234567 │ 20250601 │ 20250630 │
|
||||||
|
└──────────────────────────────┴──────────┴──────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Columna Días:** máscara numérica donde cada dígito representa un día de la semana:
|
||||||
|
|
||||||
|
```
|
||||||
|
1 = Lunes 2 = Martes 3 = Miércoles 4 = Jueves
|
||||||
|
5 = Viernes 6 = Sábado 7 = Domingo
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo: `135` = solo Lunes, Miércoles y Viernes.
|
||||||
|
|
||||||
|
**Columna Inicio / Fin:** fechas en formato `YYYYMMDD`. Fuera de ese rango el audio no se emite.
|
||||||
|
|
||||||
|
### 9.4 Barra de herramientas
|
||||||
|
|
||||||
|
| Botón | Función |
|
||||||
|
|---|---|
|
||||||
|
| + Nueva | Abre el diálogo para agregar una entrada |
|
||||||
|
| ✏ Editar | Edita la entrada seleccionada |
|
||||||
|
| ✗ Eliminar | Borra la entrada seleccionada |
|
||||||
|
| ↑ / ↓ | Cambia el orden de reproducción |
|
||||||
|
| 💾 Guardar | Envía los cambios al servidor |
|
||||||
|
|
||||||
|
> Los cambios **no se aplican hasta presionar 💾 Guardar**.
|
||||||
|
|
||||||
|
### 9.5 Diálogo de nueva entrada / edición
|
||||||
|
|
||||||
|
```
|
||||||
|
Ruta: [/radio/comerciales/spot1.mp3 ]
|
||||||
|
|
||||||
|
Días activos:
|
||||||
|
[✓] Lun [✓] Mar [✓] Mié [✓] Jue [✓] Vie [ ] Sáb [ ] Dom
|
||||||
|
|
||||||
|
Inicio: [20250101] Fin: [20251231]
|
||||||
|
|
||||||
|
[Cancelar] [Aceptar]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Ruta:** ruta completa del archivo en el servidor. Se puede pegar desde el buscador.
|
||||||
|
- **Días activos:** marcar los días en que debe emitirse.
|
||||||
|
- **Inicio / Fin:** período de vigencia en formato `YYYYMMDD`.
|
||||||
|
|
||||||
|
### 9.6 Drag-and-drop desde el buscador
|
||||||
|
|
||||||
|
Arrastrar un archivo desde el Buscador hacia la tabla de pautaje crea automáticamente una nueva entrada con:
|
||||||
|
|
||||||
|
- Ruta = archivo arrastrado
|
||||||
|
- Días = todos (1234567)
|
||||||
|
- Inicio y Fin = fecha actual
|
||||||
|
|
||||||
|
Editar la entrada para ajustar días y fechas según corresponda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Panel Parrilla
|
||||||
|
|
||||||
|
Permite editar la programación musical horaria del servidor.
|
||||||
|
|
||||||
|
### 10.1 Selección de día y hora
|
||||||
|
|
||||||
|
```
|
||||||
|
Día: [Lunes ▾] Hora: [10 ▾] [⟳ Cargar]
|
||||||
|
```
|
||||||
|
|
||||||
|
Seleccionar el día de la semana y la hora (00–23), luego hacer clic en **⟳ Cargar**.
|
||||||
|
|
||||||
|
### 10.2 Lista de items
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────┬──────────────────────────────────────────────┐
|
||||||
|
│ # │ Item │
|
||||||
|
├────┼──────────────────────────────────────────────┤
|
||||||
|
│ 1 │ Hora │
|
||||||
|
│ 2 │ /radio/musica/pop/* │
|
||||||
|
│ 3 │ /radio/musica/pop/cancion-especial.mp3 │
|
||||||
|
│ 4 │ /radio/musica/rock/* │
|
||||||
|
└────┴──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Tipos de item:
|
||||||
|
|
||||||
|
| Item | Comportamiento |
|
||||||
|
|---|---|
|
||||||
|
| `/ruta/archivo.mp3` | Reproduce ese archivo exacto |
|
||||||
|
| `/ruta/carpeta/*` | Elige un archivo al azar de esa carpeta |
|
||||||
|
| `Hora` | Reproduce el jingle de hora correspondiente |
|
||||||
|
| `http://stream.url` | Conecta al stream de internet |
|
||||||
|
|
||||||
|
### 10.3 Barra de herramientas
|
||||||
|
|
||||||
|
| Botón | Función |
|
||||||
|
|---|---|
|
||||||
|
| + Archivo | Agrega una ruta de archivo específico |
|
||||||
|
| + Carpeta/* | Agrega una carpeta para selección aleatoria |
|
||||||
|
| + Hora | Inserta el marcador de jingle de hora |
|
||||||
|
| + URL | Agrega una URL de streaming |
|
||||||
|
| ✗ Eliminar | Elimina el item seleccionado |
|
||||||
|
| ↑ / ↓ | Cambia el orden |
|
||||||
|
| 💾 Guardar | Envía los cambios al servidor |
|
||||||
|
|
||||||
|
> Los cambios **no se aplican hasta presionar 💾 Guardar**.
|
||||||
|
|
||||||
|
### 10.4 Drag-and-drop desde el buscador
|
||||||
|
|
||||||
|
Arrastrar un archivo desde el Buscador hacia la lista de parrilla lo agrega como item de archivo específico en la posición donde se suelta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Panel Botonera
|
||||||
|
|
||||||
|
La botonera es una grilla de botones de acceso rápido para reproducir audios con un solo clic.
|
||||||
|
|
||||||
|
### 11.1 Vista de la botonera
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┬────────────┬────────────┬────────────┐
|
||||||
|
│ Intro │ Cierre │ Cortina │ Jingle 1 │
|
||||||
|
│ (rojo) │ (azul) │ (verde) │ (naranja) │
|
||||||
|
├────────────┼────────────┼────────────┼────────────┤
|
||||||
|
│ ... │ ... │ ... │ ... │
|
||||||
|
└────────────┴────────────┴────────────┴────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Hasta 100 botones organizados en filas de 8.
|
||||||
|
- Cada botón tiene etiqueta, color y una ruta de audio asignada.
|
||||||
|
|
||||||
|
### 11.2 Usar un botón
|
||||||
|
|
||||||
|
**Clic izquierdo** sobre cualquier botón → el servidor reproduce ese audio inmediatamente (interrumpe la música con duck de volumen si está configurado).
|
||||||
|
|
||||||
|
### 11.3 Editar un botón
|
||||||
|
|
||||||
|
**Clic derecho** sobre un botón → abre el diálogo de edición:
|
||||||
|
|
||||||
|
```
|
||||||
|
Etiqueta: [Intro programa ]
|
||||||
|
Ruta: [/radio/jingles/intro.mp3]
|
||||||
|
Color: [█ #FF5500 ] (selector de color)
|
||||||
|
|
||||||
|
[Cancelar] [Aceptar]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 Guardar cambios
|
||||||
|
|
||||||
|
Los cambios en la botonera se envían al servidor con el botón **💾 Guardar** de la barra de herramientas. Para recargar desde el servidor usar **⟳ Cargar**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Panel Reportes
|
||||||
|
|
||||||
|
Muestra reportes de actividad generados por el servidor.
|
||||||
|
|
||||||
|
### 12.1 Uso
|
||||||
|
|
||||||
|
```
|
||||||
|
Reporte: [Audios emitidos ▾] Fecha: [20260419] [⟳ Cargar]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Seleccionar el tipo de reporte.
|
||||||
|
2. Ingresar la fecha en formato `YYYYMMDD` (por defecto: hoy).
|
||||||
|
3. Hacer clic en **⟳ Cargar**.
|
||||||
|
|
||||||
|
### 12.2 Tipos de reporte disponibles
|
||||||
|
|
||||||
|
| Tipo | Contenido |
|
||||||
|
|---|---|
|
||||||
|
| Audios emitidos | Lista de todos los audios reproducidos en la fecha indicada |
|
||||||
|
| Playlist actual | Estado actual de la cola de música |
|
||||||
|
| Comerciales activos | Pautaje vigente para el día y hora actual |
|
||||||
|
| Eventos activos | Eventos programados vigentes |
|
||||||
|
|
||||||
|
El resultado se muestra en una vista de texto de solo lectura, útil para auditoría y verificación de programación.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Conexión por Internet (relay)
|
||||||
|
|
||||||
|
El sistema de relay permite acceder al servidor desde fuera de la red local sin necesidad de configurar puertos en el router.
|
||||||
|
|
||||||
|
### 13.1 Cómo funciona
|
||||||
|
|
||||||
|
```
|
||||||
|
gr-client ──WSS──→ relay.gradio.net ──TCP──→ servidor G Radio Player
|
||||||
|
Internet LAN local
|
||||||
|
```
|
||||||
|
|
||||||
|
El servidor G Radio Player mantiene una conexión persistente con el relay usando un **ID de 8 dígitos**. El cliente se conecta al relay con ese mismo ID y el tráfico se reenvía transparentemente.
|
||||||
|
|
||||||
|
### 13.2 Configuración en el servidor
|
||||||
|
|
||||||
|
Editar `~/.gradio/data/tmp/gradio.config` en el equipo servidor:
|
||||||
|
|
||||||
|
```
|
||||||
|
...
|
||||||
|
1 ← línea 7: relay habilitado (1 = sí)
|
||||||
|
12345678 ← línea 8: ID de 8 dígitos (elegir uno único)
|
||||||
|
```
|
||||||
|
|
||||||
|
Reiniciar el servidor para que tome efecto. Al iniciar, el servidor mostrará en consola:
|
||||||
|
|
||||||
|
```
|
||||||
|
==> Relay registrado con ID: 12345678
|
||||||
|
```
|
||||||
|
|
||||||
|
**Importante:** el token de autenticación es obligatorio cuando se usa relay.
|
||||||
|
|
||||||
|
### 13.3 Conexión desde el cliente
|
||||||
|
|
||||||
|
1. Activar el checkbox **Internet** en el panel de conexión.
|
||||||
|
2. Ingresar el **ID** de 8 dígitos del servidor.
|
||||||
|
3. Ingresar el **Token** (obligatorio).
|
||||||
|
4. Hacer clic en **Conectar**.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────────┐
|
||||||
|
│ [✓] Internet ID: [12345678] Token: [mi-token-seguro] │
|
||||||
|
│ [Conectar] │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
> En modo relay el campo Servidor y Puerto se ignoran.
|
||||||
|
|
||||||
|
### 13.4 Seguridad
|
||||||
|
|
||||||
|
- Usar siempre un token robusto en conexiones por internet.
|
||||||
|
- El tráfico entre cliente y relay viaja cifrado (WSS/TLS).
|
||||||
|
- El ID del relay no es secreto, pero sin el token correcto no se puede controlar el servidor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Solución de problemas
|
||||||
|
|
||||||
|
### No se puede conectar (LAN)
|
||||||
|
|
||||||
|
1. Verificar que el servidor esté ejecutándose:
|
||||||
|
```bash
|
||||||
|
ps aux | grep radio-player
|
||||||
|
```
|
||||||
|
2. Verificar que el puerto esté abierto:
|
||||||
|
```bash
|
||||||
|
ss -tlnp | grep 7777
|
||||||
|
```
|
||||||
|
3. Verificar que el token ingresado en el cliente coincide con el del servidor.
|
||||||
|
4. Verificar que no hay firewall bloqueando el puerto:
|
||||||
|
```bash
|
||||||
|
# En el servidor
|
||||||
|
sudo ufw allow 7777/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### No se puede conectar (relay)
|
||||||
|
|
||||||
|
1. Verificar que el relay esté habilitado en `gradio.config` (línea 7 = `1`).
|
||||||
|
2. Verificar que el servidor haya impreso `==> Relay registrado con ID: XXXXXXXX` al iniciar.
|
||||||
|
3. Verificar que el ID ingresado en el cliente coincide exactamente con el del servidor.
|
||||||
|
4. Verificar que el token no está vacío (obligatorio en modo relay).
|
||||||
|
5. Verificar conectividad a internet desde el servidor.
|
||||||
|
|
||||||
|
### El buscador no encuentra archivos recientes
|
||||||
|
|
||||||
|
Hacer clic en **⟳ Índice** en el panel Buscador para actualizar la base de datos del servidor. El proceso puede tardar unos minutos dependiendo de la cantidad de archivos.
|
||||||
|
|
||||||
|
### Los cambios en parrilla o pautaje no surten efecto
|
||||||
|
|
||||||
|
Verificar que se presionó **💾 Guardar** después de editar. Los cambios no se envían al servidor hasta confirmarlos.
|
||||||
|
|
||||||
|
### La playlist no se actualiza automáticamente
|
||||||
|
|
||||||
|
La actualización automática requiere conexión activa. Verificar el indicador de estado en el panel de conexión. Si está en rojo, el cliente perdió la conexión y está intentando reconectarse.
|
||||||
|
|
||||||
|
### El servidor muestra "cliente conectado" pero los controles están deshabilitados
|
||||||
|
|
||||||
|
Cerrar y reabrir la aplicación cliente. Si persiste, verificar que la versión del servidor es compatible (0.2.9 o posterior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*G Radio Player / G Radio Client — Sistema de automatización de radio profesional*
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,480 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="210mm"
|
||||||
|
height="297mm"
|
||||||
|
viewBox="0 0 210 297"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:export-filename="gr-client.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||||
|
sodipodi:docname="gr-client.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="0.74564394"
|
||||||
|
inkscape:cx="396.97231"
|
||||||
|
inkscape:cy="561.9304"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1003"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" /><defs
|
||||||
|
id="defs2" /><g
|
||||||
|
inkscape:label="Capa 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"><image
|
||||||
|
width="135.46666"
|
||||||
|
height="136.78958"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
xlink:href="data:image/png;base64,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
|
||||||
|
fh4VfBeRmPl/IXU8+Oty5uUAAAAASUVORK5CYII=
|
||||||
|
"
|
||||||
|
id="image140"
|
||||||
|
x="57.524734"
|
||||||
|
y="63.960049"
|
||||||
|
inkscape:export-filename="gr-client.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96" /><path
|
||||||
|
d="m 64.326529,115.06892 q -0.956472,-1.70088 -0.652091,-3.32223 0.304373,-1.62134 1.726381,-2.40018 l 5.469221,-2.99551 -2.942996,-5.23346 16.4077,-8.986565 q 1.422002,-0.778832 2.97715,-0.175963 1.555162,0.602871 2.511633,2.303744 l 3.678745,6.541814 33.909218,-18.572212 q 7.00062,-3.834259 15.16218,-0.261103 8.16155,3.573161 13.01751,12.208367 4.63522,8.242678 3.07748,16.540158 -1.55771,8.2975 -8.44895,12.07184 l -45.9415,25.16235 q -4.15659,2.27665 -5.087682,7.23602 -0.931038,4.95943 1.864802,9.9312 3.01657,5.36427 7.97634,7.69625 4.95977,2.33194 9.22576,-0.004 l 33.90924,-18.57219 -3.67876,-6.54182 q -0.95641,-1.70092 -0.6521,-3.32224 0.3044,-1.62134 1.72639,-2.40015 l 16.40768,-8.98659 2.94298,5.2335 5.46923,-2.99554 q 1.42204,-0.77873 2.97714,-0.17597 1.55518,0.60289 2.51165,2.30375 l 5.88598,10.4669 q 0.95642,1.7009 0.65209,3.32224 -0.30437,1.62135 -1.72638,2.40017 l -5.46922,2.99554 2.94295,5.23346 -16.40771,8.98657 q -1.42187,0.77898 -2.97715,0.17596 -1.55514,-0.60287 -2.51162,-2.30375 l -3.67873,-6.54181 -33.90924,18.57221 q -7.00061,3.83429 -15.16217,0.26109 -8.161553,-3.57315 -13.017499,-12.20834 -4.635227,-8.2427 -3.077509,-16.5402 1.557725,-8.29745 8.448959,-12.07181 L 145.80512,114.9379 q 4.15662,-2.27665 5.08771,-7.23599 0.93101,-4.95942 -1.86483,-9.931213 -3.01657,-5.364277 -7.97632,-7.696245 -4.95977,-2.331951 -9.22578,0.0046 l -33.909212,18.572218 3.678752,6.54179 q 0.95642,1.70092 0.65207,3.32224 -0.30436,1.62134 -1.72636,2.40017 l -16.407703,8.98655 -2.942983,-5.23343 -5.46921,2.99554 q -1.421968,0.77892 -2.977165,0.17595 -1.555161,-0.60288 -2.511629,-2.30374 z"
|
||||||
|
id="path248"
|
||||||
|
style="fill:#00ff00;fill-opacity:1;stroke-width:0.136817" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# build.sh — Construye gr-client
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Instalando dependencias del sistema ==="
|
||||||
|
sudo apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
libgtk-4-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgdk-pixbuf-2.0-dev \
|
||||||
|
libcairo2-dev \
|
||||||
|
libpango1.0-dev \
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
|
echo "=== Compilando gr-client ==="
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Compilación exitosa."
|
||||||
|
echo " Binario: target/release/gr-client"
|
||||||
|
echo ""
|
||||||
|
echo "Para instalar en /usr/local/bin:"
|
||||||
|
echo " sudo cp target/release/gr-client /usr/local/bin/"
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ─── G Radio Client — instalador ─────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BIN_DIR="$HOME/.local/bin"
|
||||||
|
DESKTOP_DIR="$HOME/.local/share/applications"
|
||||||
|
ICON="gr-client"
|
||||||
|
|
||||||
|
# ── 1. Verificar que el binario fue compilado ─────────────────────────────────
|
||||||
|
if [ ! -f "target/release/gr-client" ]; then
|
||||||
|
echo "ERROR: target/release/gr-client no encontrado."
|
||||||
|
echo " Compila el proyecto antes de instalar:"
|
||||||
|
echo " cargo build --release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Dependencias del sistema ───────────────────────────────────────────────
|
||||||
|
echo "==> Verificando dependencias..."
|
||||||
|
|
||||||
|
if command -v apt &>/dev/null; then
|
||||||
|
MISSING=()
|
||||||
|
for pkg in libgtk-4-1 libglib2.0-0 ffmpeg mlocate; do
|
||||||
|
if ! dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
|
||||||
|
MISSING+=("$pkg")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||||
|
echo " Instalando: ${MISSING[*]}"
|
||||||
|
sudo apt install -y "${MISSING[@]}"
|
||||||
|
else
|
||||||
|
echo " Dependencias OK."
|
||||||
|
fi
|
||||||
|
elif command -v dnf &>/dev/null; then
|
||||||
|
sudo dnf install -y gtk4 glib2 ffmpeg mlocate 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " AVISO: gestor de paquetes no reconocido."
|
||||||
|
echo " Instala manualmente: gtk4, ffmpeg, mlocate (o plocate)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Instalar binario ───────────────────────────────────────────────────────
|
||||||
|
echo "==> Instalando gr-client en $BIN_DIR..."
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
cp target/release/gr-client "$BIN_DIR/gr-client"
|
||||||
|
chmod +x "$BIN_DIR/gr-client"
|
||||||
|
|
||||||
|
# ── 4. Crear directorios de configuración ────────────────────────────────────
|
||||||
|
echo "==> Preparando directorios de configuración..."
|
||||||
|
mkdir -p "$HOME/.gradio/data" # para la base de datos de locate (locatedb)
|
||||||
|
|
||||||
|
# ── 5. Entrada de menú de escritorio ─────────────────────────────────────────
|
||||||
|
echo "==> Registrando en el menú de aplicaciones..."
|
||||||
|
mkdir -p "$DESKTOP_DIR"
|
||||||
|
cat > "$DESKTOP_DIR/gr-client.desktop" << DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=G Radio Client
|
||||||
|
GenericName=Radio Remote Client
|
||||||
|
Comment=Cliente remoto para G Radio Player
|
||||||
|
Exec=$BIN_DIR/gr-client
|
||||||
|
Icon=$ICON
|
||||||
|
Categories=AudioVideo;Audio;
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
DESKTOP
|
||||||
|
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 6. Advertir si ~/.local/bin no está en PATH ───────────────────────────────
|
||||||
|
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||||
|
echo ""
|
||||||
|
echo " AVISO: $HOME/.local/bin no está en tu PATH."
|
||||||
|
echo " Agrega esta línea a tu ~/.bashrc o ~/.zshrc:"
|
||||||
|
echo ""
|
||||||
|
echo ' export PATH="$HOME/.local/bin:$PATH"'
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Instalación completa."
|
||||||
|
echo ""
|
||||||
|
echo " Ejecutar: gr-client"
|
||||||
|
echo " O desde menú: busca 'G Radio Client'"
|
||||||
|
echo ""
|
||||||
|
echo " Configuración: ~/.gradio/client_config.json"
|
||||||
|
echo " Índice locate: ~/.gradio/data/locatedb (generado con ⟳ Índice en el buscador)"
|
||||||
|
echo ""
|
||||||
|
echo " Para desinstalar: ./uninstall.sh"
|
||||||
Executable
+155
@@ -0,0 +1,155 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# package-deb.sh — Empaqueta G Radio Client como .deb instalable
|
||||||
|
# Requiere: binario ya compilado en target/release/
|
||||||
|
# Uso: ./package-deb.sh [versión] (default: 0.1.1)
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION="${1:-0.1.1}"
|
||||||
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
PKG_NAME="gr-client"
|
||||||
|
PKG_DIR="${PKG_NAME}_${VERSION}_${ARCH}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# ── Verificar que el binario existe ───────────────────────────────────────────
|
||||||
|
if [ ! -f "$ROOT/target/release/gr-client" ]; then
|
||||||
|
echo "[!] Binario faltante: target/release/gr-client"
|
||||||
|
echo " Ejecuta primero: cargo build --release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " Empaquetando $PKG_NAME $VERSION ($ARCH)"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
# ── Limpiar directorio anterior ───────────────────────────────────────────────
|
||||||
|
rm -rf "$PKG_DIR"
|
||||||
|
|
||||||
|
# ── Estructura de directorios ─────────────────────────────────────────────────
|
||||||
|
mkdir -p "$PKG_DIR/DEBIAN"
|
||||||
|
mkdir -p "$PKG_DIR/usr/local/bin"
|
||||||
|
mkdir -p "$PKG_DIR/usr/share/applications"
|
||||||
|
mkdir -p "$PKG_DIR/usr/share/icons/hicolor/48x48/apps"
|
||||||
|
|
||||||
|
# ── Copiar binario ────────────────────────────────────────────────────────────
|
||||||
|
echo "[*] Copiando binario..."
|
||||||
|
cp "$ROOT/target/release/gr-client" "$PKG_DIR/usr/local/bin/gr-client"
|
||||||
|
chmod 755 "$PKG_DIR/usr/local/bin/gr-client"
|
||||||
|
|
||||||
|
# ── Icono (reutiliza el del server si está disponible, sino genérico) ─────────
|
||||||
|
SERVER_ICON="../radio-player/assets/gradio.png"
|
||||||
|
if [ -f "$ROOT/assets/gr-client.png" ]; then
|
||||||
|
cp "$ROOT/assets/gr-client.png" \
|
||||||
|
"$PKG_DIR/usr/share/icons/hicolor/48x48/apps/gr-client.png"
|
||||||
|
elif [ -f "$ROOT/$SERVER_ICON" ]; then
|
||||||
|
cp "$ROOT/$SERVER_ICON" \
|
||||||
|
"$PKG_DIR/usr/share/icons/hicolor/48x48/apps/gr-client.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Entrada de escritorio ─────────────────────────────────────────────────────
|
||||||
|
echo "[*] Creando entrada de escritorio..."
|
||||||
|
cat > "$PKG_DIR/usr/share/applications/gr-client.desktop" << DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=G Radio Client
|
||||||
|
GenericName=Radio Remote Client
|
||||||
|
Comment=Cliente remoto para G Radio Player
|
||||||
|
Exec=/usr/local/bin/gr-client
|
||||||
|
Icon=gr-client
|
||||||
|
Categories=AudioVideo;Audio;
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
Keywords=radio;audio;remote;client;
|
||||||
|
DESKTOP
|
||||||
|
|
||||||
|
# ── DEBIAN/control ────────────────────────────────────────────────────────────
|
||||||
|
echo "[*] Generando DEBIAN/control..."
|
||||||
|
INSTALLED_SIZE=$(du -sk "$PKG_DIR/usr" | cut -f1)
|
||||||
|
|
||||||
|
cat > "$PKG_DIR/DEBIAN/control" << CONTROL
|
||||||
|
Package: gr-client
|
||||||
|
Version: ${VERSION}
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: G Radio <gradio@example.com>
|
||||||
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
|
Depends: libgtk-4-1 (>= 4.6),
|
||||||
|
libglib2.0-0 (>= 2.72),
|
||||||
|
ffmpeg,
|
||||||
|
mlocate | plocate,
|
||||||
|
libc6 (>= 2.17)
|
||||||
|
Recommends: gradio-player (>= 0.2.8)
|
||||||
|
Section: sound
|
||||||
|
Priority: optional
|
||||||
|
Description: G Radio Client — cliente remoto para G Radio Player
|
||||||
|
Panel GTK4 que permite controlar remotamente un servidor G Radio Player
|
||||||
|
via TCP (LAN o internet con relay). Incluye player básico con drag-and-drop,
|
||||||
|
editor de pautaje y parrilla, buscador de audio, botonera y visor de reportes.
|
||||||
|
.
|
||||||
|
Requiere un servidor G Radio Player (gradio-player >= 0.2.8) accesible
|
||||||
|
en la red local en el puerto configurado (default: 7777).
|
||||||
|
CONTROL
|
||||||
|
|
||||||
|
# ── DEBIAN/postinst ───────────────────────────────────────────────────────────
|
||||||
|
cat > "$PKG_DIR/DEBIAN/postinst" << 'POSTINST'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Crear directorio de datos si no existe
|
||||||
|
mkdir -p "$HOME/.gradio/data" 2>/dev/null || true
|
||||||
|
|
||||||
|
if command -v gtk-update-icon-cache &>/dev/null; then
|
||||||
|
gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if command -v update-desktop-database &>/dev/null; then
|
||||||
|
update-desktop-database /usr/share/applications 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "G Radio Client instalado correctamente."
|
||||||
|
echo "Ejecutar: gr-client o buscar 'G Radio Client' en el menú."
|
||||||
|
echo ""
|
||||||
|
echo "Nota: el índice de búsqueda se genera la primera vez que presionas"
|
||||||
|
echo "'⟳ Índice' en el buscador (no requiere sudo)."
|
||||||
|
POSTINST
|
||||||
|
chmod 755 "$PKG_DIR/DEBIAN/postinst"
|
||||||
|
|
||||||
|
# ── DEBIAN/prerm ──────────────────────────────────────────────────────────────
|
||||||
|
cat > "$PKG_DIR/DEBIAN/prerm" << 'PRERM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
pkill -f "gr-client" 2>/dev/null || true
|
||||||
|
PRERM
|
||||||
|
chmod 755 "$PKG_DIR/DEBIAN/prerm"
|
||||||
|
|
||||||
|
# ── DEBIAN/postrm ─────────────────────────────────────────────────────────────
|
||||||
|
cat > "$PKG_DIR/DEBIAN/postrm" << 'POSTRM'
|
||||||
|
#!/bin/bash
|
||||||
|
rm -f /usr/local/bin/gr-client
|
||||||
|
rm -f /usr/share/applications/gr-client.desktop
|
||||||
|
rm -f /usr/share/icons/hicolor/48x48/apps/gr-client.png
|
||||||
|
|
||||||
|
if command -v gtk-update-icon-cache &>/dev/null; then
|
||||||
|
gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if command -v update-desktop-database &>/dev/null; then
|
||||||
|
update-desktop-database /usr/share/applications 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
POSTRM
|
||||||
|
chmod 755 "$PKG_DIR/DEBIAN/postrm"
|
||||||
|
|
||||||
|
# ── Construir el .deb ─────────────────────────────────────────────────────────
|
||||||
|
echo "[*] Construyendo paquete .deb..."
|
||||||
|
dpkg-deb --build --root-owner-group "$PKG_DIR"
|
||||||
|
|
||||||
|
DEB_FILE="${PKG_DIR}.deb"
|
||||||
|
SIZE=$(du -sh "$DEB_FILE" | cut -f1)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " Paquete listo: $DEB_FILE ($SIZE)"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo " Instalar: sudo dpkg -i $DEB_FILE"
|
||||||
|
echo " O con apt: sudo apt install ./$DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
echo " Desinstalar: sudo apt remove gr-client"
|
||||||
|
echo ""
|
||||||
+456
@@ -0,0 +1,456 @@
|
|||||||
|
// conexion.rs — Gestión de la conexión al servidor gr-client
|
||||||
|
//
|
||||||
|
// Soporta dos modos de transporte:
|
||||||
|
// LAN: TCP directo → ip:puerto
|
||||||
|
// Internet: WebSocket WSS → relay.gradio.net/ws con ID de 8 dígitos
|
||||||
|
//
|
||||||
|
// Arquitectura de hilos:
|
||||||
|
// GTK main thread ←→ std::sync::mpsc::SyncSender ←→ tokio task ←→ TCP/WS
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::sync::mpsc::SyncSender;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::sync::mpsc::{self, UnboundedSender};
|
||||||
|
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector};
|
||||||
|
|
||||||
|
use crate::protocol::{MsgCliente, MsgServidor};
|
||||||
|
|
||||||
|
const RELAY_URL: &str = "wss://relay.gradio.net/ws";
|
||||||
|
|
||||||
|
/// Runtime tokio global (creado una sola vez)
|
||||||
|
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
||||||
|
|
||||||
|
fn rt() -> &'static tokio::runtime::Runtime {
|
||||||
|
RT.get_or_init(|| {
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("No se pudo crear runtime tokio")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Estado de conexión ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum EstadoCon {
|
||||||
|
Desconectado,
|
||||||
|
Conectando,
|
||||||
|
Conectado { version: String },
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Conexión ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Conexion {
|
||||||
|
tx: UnboundedSender<MsgCliente>,
|
||||||
|
activa: Arc<Mutex<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Conexion {
|
||||||
|
/// Conexión LAN: TCP directo al servidor.
|
||||||
|
pub fn conectar(
|
||||||
|
servidor: String,
|
||||||
|
puerto: u16,
|
||||||
|
token: String,
|
||||||
|
tx_server: SyncSender<MsgServidor>,
|
||||||
|
tx_estado: SyncSender<EstadoCon>,
|
||||||
|
) -> Self {
|
||||||
|
let (tx, rx_cmd) = mpsc::unbounded_channel::<MsgCliente>();
|
||||||
|
let activa = Arc::new(Mutex::new(false));
|
||||||
|
let activa2 = activa.clone();
|
||||||
|
|
||||||
|
rt().spawn(async move {
|
||||||
|
tarea_tcp(servidor, puerto, token, rx_cmd, tx_server, tx_estado, activa2).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Conexion { tx, activa }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conexión Internet: WebSocket via relay.gradio.net.
|
||||||
|
pub fn conectar_relay(
|
||||||
|
relay_id: String,
|
||||||
|
token: String,
|
||||||
|
tx_server: SyncSender<MsgServidor>,
|
||||||
|
tx_estado: SyncSender<EstadoCon>,
|
||||||
|
) -> Self {
|
||||||
|
let (tx, rx_cmd) = mpsc::unbounded_channel::<MsgCliente>();
|
||||||
|
let activa = Arc::new(Mutex::new(false));
|
||||||
|
let activa2 = activa.clone();
|
||||||
|
|
||||||
|
rt().spawn(async move {
|
||||||
|
tarea_ws_relay(relay_id, token, rx_cmd, tx_server, tx_estado, activa2).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Conexion { tx, activa }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envía un mensaje al servidor (no-bloquea)
|
||||||
|
pub fn enviar(&self, msg: MsgCliente) {
|
||||||
|
let _ = self.tx.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn esta_activa(&self) -> bool {
|
||||||
|
*self.activa.lock().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tarea TCP (LAN) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn tarea_tcp(
|
||||||
|
servidor: String,
|
||||||
|
puerto: u16,
|
||||||
|
token: String,
|
||||||
|
mut rx_cmd: mpsc::UnboundedReceiver<MsgCliente>,
|
||||||
|
tx_server: SyncSender<MsgServidor>,
|
||||||
|
tx_estado: SyncSender<EstadoCon>,
|
||||||
|
activa: Arc<Mutex<bool>>,
|
||||||
|
) {
|
||||||
|
let addr = format!("{}:{}", servidor, puerto);
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match TcpStream::connect(&addr).await {
|
||||||
|
Err(e) => {
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(
|
||||||
|
format!("{}:{} — {}", servidor, puerto, e)
|
||||||
|
));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
|
||||||
|
msg = rx_cmd.recv() => {
|
||||||
|
if msg.is_none() { break; } // usuario canceló
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(stream) => {
|
||||||
|
*activa.lock().unwrap() = true;
|
||||||
|
log::info!("TCP conectado a {}", addr);
|
||||||
|
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(read_half);
|
||||||
|
|
||||||
|
let auth_json = serde_json::to_string(
|
||||||
|
&MsgCliente::Auth { token: token.clone() }
|
||||||
|
).unwrap() + "\n";
|
||||||
|
|
||||||
|
if write_half.write_all(auth_json.as_bytes()).await.is_err() {
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
let _ = tx_estado.send(EstadoCon::Desconectado);
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx_write, mut rx_write) = mpsc::unbounded_channel::<String>();
|
||||||
|
let escritor = tokio::spawn(async move {
|
||||||
|
while let Some(json) = rx_write.recv().await {
|
||||||
|
if write_half.write_all(json.as_bytes()).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timer de inactividad: si no llega ningún dato en 90s → cerrar
|
||||||
|
const TIMEOUT_TCP: Duration = Duration::from_secs(90);
|
||||||
|
let mut linea = String::new();
|
||||||
|
let mut desconectado = false;
|
||||||
|
loop {
|
||||||
|
linea.clear();
|
||||||
|
tokio::select! {
|
||||||
|
result = tokio::time::timeout(TIMEOUT_TCP, reader.read_line(&mut linea)) => {
|
||||||
|
match result {
|
||||||
|
// Timeout de inactividad — enviar Ping para verificar si el servidor sigue vivo
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!("TCP: timeout inactividad, enviando Ping");
|
||||||
|
let ping = serde_json::to_string(&MsgCliente::Ping).unwrap() + "\n";
|
||||||
|
if tx_write.send(ping).is_err() {
|
||||||
|
desconectado = true; break;
|
||||||
|
}
|
||||||
|
// Si el servidor no responde, el siguiente timeout cerrará la conexión
|
||||||
|
}
|
||||||
|
Ok(Ok(0)) | Ok(Err(_)) => { desconectado = true; break; }
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
let trimmed = linea.trim();
|
||||||
|
if trimmed.is_empty() { continue; }
|
||||||
|
match serde_json::from_str::<MsgServidor>(trimmed) {
|
||||||
|
Ok(MsgServidor::AuthOk { version }) => {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectado { version });
|
||||||
|
let _ = tx_write.send(serde_json::to_string(&MsgCliente::ObtenerEstado).unwrap() + "\n");
|
||||||
|
let _ = tx_write.send(serde_json::to_string(&MsgCliente::ObtenerPlaylist).unwrap() + "\n");
|
||||||
|
let _ = tx_write.send(serde_json::to_string(&MsgCliente::ObtenerBotonera).unwrap() + "\n");
|
||||||
|
}
|
||||||
|
Ok(MsgServidor::Pong) => {} // respuesta al Ping — conexión viva
|
||||||
|
Ok(msg) => { let _ = tx_server.send(msg); }
|
||||||
|
Err(e) => log::warn!("JSON inválido: {} — {:?}", trimmed, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd = rx_cmd.recv() => {
|
||||||
|
match cmd {
|
||||||
|
None => break,
|
||||||
|
Some(msg) => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
if tx_write.send(json + "\n").is_err() {
|
||||||
|
desconectado = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escritor.abort();
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
|
||||||
|
if desconectado {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Desconectado);
|
||||||
|
log::info!("TCP desconectado de {}. Reintentando en 5s…", addr);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
|
||||||
|
msg = rx_cmd.recv() => {
|
||||||
|
if msg.is_none() { break; } // usuario canceló
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
let _ = tx_estado.send(EstadoCon::Desconectado);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tarea WebSocket relay (Internet) ────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn tarea_ws_relay(
|
||||||
|
relay_id: String,
|
||||||
|
token: String,
|
||||||
|
mut rx_cmd: mpsc::UnboundedReceiver<MsgCliente>,
|
||||||
|
tx_server: SyncSender<MsgServidor>,
|
||||||
|
tx_estado: SyncSender<EstadoCon>,
|
||||||
|
activa: Arc<Mutex<bool>>,
|
||||||
|
) {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
let mut delay_secs = 5u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// ── Conectar WS al relay ──────────────────────────────────────────────
|
||||||
|
let connector = match native_tls::TlsConnector::new() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(format!("TLS: {}", e)));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(10)) => {}
|
||||||
|
msg = rx_cmd.recv() => { if msg.is_none() { break; } }
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ws_result = connect_async_tls_with_config(
|
||||||
|
RELAY_URL, None, false,
|
||||||
|
Some(Connector::NativeTls(connector)),
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let (ws, _) = match ws_result {
|
||||||
|
Ok(w) => { delay_secs = 5; w }
|
||||||
|
Err(e) => {
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(
|
||||||
|
format!("relay.gradio.net: {}", e)
|
||||||
|
));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(delay_secs)) => {}
|
||||||
|
msg = rx_cmd.recv() => { if msg.is_none() { break; } }
|
||||||
|
}
|
||||||
|
delay_secs = (delay_secs * 2).min(60);
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut ws_sink, mut ws_rx) = ws.split();
|
||||||
|
|
||||||
|
// ── Handshake con el relay ────────────────────────────────────────────
|
||||||
|
let connect_msg = format!(r#"{{"tipo":"connect","id":"{}"}}"#, relay_id);
|
||||||
|
if ws_sink.send(Message::Text(connect_msg.into())).await.is_err() {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error("Error conectando al relay".to_string()));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
|
||||||
|
msg = rx_cmd.recv() => { if msg.is_none() { break; } }
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handshake con timeout: si el relay no responde en 15s es que algo falla
|
||||||
|
let respuesta_handshake = tokio::time::timeout(
|
||||||
|
Duration::from_secs(15),
|
||||||
|
ws_rx.next(),
|
||||||
|
).await;
|
||||||
|
|
||||||
|
match respuesta_handshake {
|
||||||
|
Ok(Some(Ok(Message::Text(t)))) => {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
|
||||||
|
match v["tipo"].as_str().unwrap_or("").to_lowercase().as_str() {
|
||||||
|
"connected" => {} // OK — radio-player registrado y listo
|
||||||
|
"error" => {
|
||||||
|
let msg = v["msg"].as_str().unwrap_or("ID no encontrado").to_string();
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(
|
||||||
|
format!("ID {}: {}", relay_id, msg)
|
||||||
|
));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(10)) => {}
|
||||||
|
m = rx_cmd.recv() => { if m.is_none() { break; } }
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(
|
||||||
|
format!("relay: respuesta inesperada: {}", t)
|
||||||
|
));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
|
||||||
|
m = rx_cmd.recv() => { if m.is_none() { break; } }
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Timeout en handshake
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(
|
||||||
|
"relay: timeout esperando confirmación".to_string()
|
||||||
|
));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
|
||||||
|
m = rx_cmd.recv() => { if m.is_none() { break; } }
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Error(
|
||||||
|
"relay: sin respuesta al connect".to_string()
|
||||||
|
));
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
|
||||||
|
m = rx_cmd.recv() => { if m.is_none() { break; } }
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("relay: conectado al servidor con ID={}", relay_id);
|
||||||
|
*activa.lock().unwrap() = true;
|
||||||
|
|
||||||
|
// ── Enviar Auth al servidor (vía relay) ───────────────────────────────
|
||||||
|
let auth_json = serde_json::to_string(
|
||||||
|
&MsgCliente::Auth { token: token.clone() }
|
||||||
|
).unwrap();
|
||||||
|
if ws_sink.send(Message::Text(auth_json.into())).await.is_err() {
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
let _ = tx_estado.send(EstadoCon::Desconectado);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(3)) => {}
|
||||||
|
m = rx_cmd.recv() => { if m.is_none() { break; } }
|
||||||
|
}
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canal interno: handler → tarea escritora WS
|
||||||
|
let (tx_write, mut rx_write) = mpsc::unbounded_channel::<String>();
|
||||||
|
let escritor = tokio::spawn(async move {
|
||||||
|
while let Some(json) = rx_write.recv().await {
|
||||||
|
// El relay.rs en radio-player espera texto sin \n
|
||||||
|
let texto = json.trim_end_matches('\n').to_string();
|
||||||
|
if ws_sink.send(Message::Text(texto.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bucle lectura WS + reenvío de comandos ────────────────────────────
|
||||||
|
let mut desconectado = false;
|
||||||
|
let mut ping_interval = tokio::time::interval(Duration::from_secs(45));
|
||||||
|
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
ping_interval.tick().await; // consumir el tick inicial inmediato
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = ws_rx.next() => {
|
||||||
|
match result {
|
||||||
|
None | Some(Err(_)) => { desconectado = true; break; }
|
||||||
|
Some(Ok(Message::Close(_))) => { desconectado = true; break; }
|
||||||
|
Some(Ok(Message::Text(texto))) => {
|
||||||
|
let trimmed = texto.trim();
|
||||||
|
if trimmed.is_empty() { continue; }
|
||||||
|
match serde_json::from_str::<MsgServidor>(trimmed) {
|
||||||
|
Ok(MsgServidor::AuthOk { version }) => {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectado { version });
|
||||||
|
let _ = tx_write.send(serde_json::to_string(&MsgCliente::ObtenerEstado).unwrap() + "\n");
|
||||||
|
let _ = tx_write.send(serde_json::to_string(&MsgCliente::ObtenerPlaylist).unwrap() + "\n");
|
||||||
|
let _ = tx_write.send(serde_json::to_string(&MsgCliente::ObtenerBotonera).unwrap() + "\n");
|
||||||
|
}
|
||||||
|
Ok(MsgServidor::Pong) => {} // respuesta al Ping — conexión viva
|
||||||
|
Ok(msg) => { let _ = tx_server.send(msg); }
|
||||||
|
Err(e) => log::warn!("relay JSON inválido: {} — {:?}", trimmed, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => {} // Ping/Pong/Binary — tokio-tungstenite maneja Ping automáticamente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd = rx_cmd.recv() => {
|
||||||
|
match cmd {
|
||||||
|
None => break, // usuario desconectó
|
||||||
|
Some(msg) => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
if tx_write.send(json + "\n").is_err() {
|
||||||
|
desconectado = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ping periódico para mantener la WS viva ante NAT/firewalls
|
||||||
|
_ = ping_interval.tick() => {
|
||||||
|
let ping = serde_json::to_string(&MsgCliente::Ping).unwrap();
|
||||||
|
if tx_write.send(ping + "\n").is_err() {
|
||||||
|
desconectado = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escritor.abort();
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
|
||||||
|
if desconectado {
|
||||||
|
let _ = tx_estado.send(EstadoCon::Desconectado);
|
||||||
|
log::info!("relay: desconectado. Reintentando en {}s…", delay_secs);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(delay_secs)) => {}
|
||||||
|
m = rx_cmd.recv() => { if m.is_none() { break; } }
|
||||||
|
}
|
||||||
|
delay_secs = (delay_secs * 2).min(60);
|
||||||
|
let _ = tx_estado.send(EstadoCon::Conectando);
|
||||||
|
} else {
|
||||||
|
break; // usuario pidió desconectar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*activa.lock().unwrap() = false;
|
||||||
|
let _ = tx_estado.send(EstadoCon::Desconectado);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// estado.rs — Estado del cliente gr-client
|
||||||
|
|
||||||
|
use crate::protocol::{EstadoRemoto, TrackRemoto};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Configuración persistente del cliente
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigCliente {
|
||||||
|
pub servidor: String,
|
||||||
|
pub puerto: u16,
|
||||||
|
pub token: String,
|
||||||
|
pub auto_conectar: bool,
|
||||||
|
/// true = conectar vía relay internet (relay.gradio.net)
|
||||||
|
#[serde(default)]
|
||||||
|
pub modo_relay: bool,
|
||||||
|
/// ID de 8 dígitos del servidor en el relay
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay_id: String,
|
||||||
|
/// Idioma forzado de la UI ("es"/"en"/"pt") o None para autodetectar
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub locale: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigCliente {
|
||||||
|
fn default() -> Self {
|
||||||
|
ConfigCliente {
|
||||||
|
servidor: "localhost".to_string(),
|
||||||
|
puerto: 7777,
|
||||||
|
token: String::new(),
|
||||||
|
auto_conectar: false,
|
||||||
|
modo_relay: false,
|
||||||
|
relay_id: String::new(),
|
||||||
|
locale: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigCliente {
|
||||||
|
pub fn ruta_archivo() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
.join(".gradio/client_config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargar() -> Self {
|
||||||
|
let path = Self::ruta_archivo();
|
||||||
|
if let Ok(data) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok(cfg) = serde_json::from_str(&data) {
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn guardar(&self) {
|
||||||
|
let path = Self::ruta_archivo();
|
||||||
|
if let Some(padre) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(padre);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||||
|
let _ = std::fs::write(&path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estado mutable compartido en el hilo GTK (Rc<RefCell<>>)
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct EstadoCliente {
|
||||||
|
pub config: ConfigCliente,
|
||||||
|
pub conectado: bool,
|
||||||
|
pub playlist: Vec<TrackRemoto>,
|
||||||
|
pub estado_player: EstadoRemoto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EstadoCliente {
|
||||||
|
pub fn nuevo() -> Self {
|
||||||
|
EstadoCliente {
|
||||||
|
config: ConfigCliente::cargar(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
//! Internacionalización del cliente gr-client (escritorio).
|
||||||
|
//!
|
||||||
|
//! Se inicializa una vez en `main()` antes de construir la UI.
|
||||||
|
//! El idioma se forza con `config.locale` o se autodetecta del entorno.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Locale { Es, En, Pt }
|
||||||
|
|
||||||
|
impl Locale {
|
||||||
|
pub fn code(self) -> &'static str {
|
||||||
|
match self { Locale::Es=>"es", Locale::En=>"en", Locale::Pt=>"pt" }
|
||||||
|
}
|
||||||
|
pub fn from_code(c: &str) -> Option<Self> {
|
||||||
|
match c { "es"=>Some(Locale::Es), "en"=>Some(Locale::En), "pt"=>Some(Locale::Pt), _=>None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static LOCALE: OnceLock<Locale> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn init(forced: Option<&str>) -> Locale {
|
||||||
|
let lc = forced
|
||||||
|
.and_then(Locale::from_code)
|
||||||
|
.or_else(detect_from_env)
|
||||||
|
.unwrap_or(Locale::En);
|
||||||
|
let _ = LOCALE.set(lc);
|
||||||
|
lc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_from_env() -> Option<Locale> {
|
||||||
|
let raw = std::env::var("LC_ALL").ok()
|
||||||
|
.or_else(|| std::env::var("LC_MESSAGES").ok())
|
||||||
|
.or_else(|| std::env::var("LANG").ok())?;
|
||||||
|
let code = raw.split(['.', '_', '@']).next()?.to_lowercase();
|
||||||
|
Locale::from_code(&code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn current() -> Locale {
|
||||||
|
*LOCALE.get().unwrap_or(&Locale::En)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tr(key: &str) -> &'static str {
|
||||||
|
let dict: Dict = match current() {
|
||||||
|
Locale::Es => ES,
|
||||||
|
Locale::En => EN,
|
||||||
|
Locale::Pt => PT,
|
||||||
|
};
|
||||||
|
for (k, v) in dict { if *k == key { return v; } }
|
||||||
|
for (k, v) in EN { if *k == key { return v; } }
|
||||||
|
Box::leak(key.to_string().into_boxed_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dict = &'static [(&'static str, &'static str)];
|
||||||
|
|
||||||
|
const ES: Dict = &[
|
||||||
|
("win.title", "G Radio Client"),
|
||||||
|
("tab.player", "▶ Player"),
|
||||||
|
("tab.search", "🔍 Búsqueda"),
|
||||||
|
("tab.parrilla", "📅 Parrilla"),
|
||||||
|
("tab.botonera", "🎛 Botonera"),
|
||||||
|
("tab.pautaje", "📋 Pautaje"),
|
||||||
|
("tab.comercios", "📢 Comercios"),
|
||||||
|
("tab.reportes", "📊 Reportes"),
|
||||||
|
("btn.connect", "Conectar"),
|
||||||
|
("btn.disconnect", "Desconectar"),
|
||||||
|
("btn.save", "Guardar"),
|
||||||
|
("btn.cancel", "Cancelar"),
|
||||||
|
("btn.ok", "OK"),
|
||||||
|
("conn.server", "Servidor:"),
|
||||||
|
("conn.port", "Puerto:"),
|
||||||
|
("conn.token", "Token:"),
|
||||||
|
("conn.auto", "Conectar automáticamente al iniciar"),
|
||||||
|
("conn.lan", "🖧 LAN"),
|
||||||
|
("conn.internet", "🌍 Internet (relay)"),
|
||||||
|
("conn.relay_id", "ID de relay (8 dígitos):"),
|
||||||
|
("conn.status_off", "● Desconectado"),
|
||||||
|
("conn.status_on", "● Conectado"),
|
||||||
|
("conn.connecting", "○ Conectando…"),
|
||||||
|
("lang.label", "Idioma:"),
|
||||||
|
("lang.auto", "Automático"),
|
||||||
|
("lang.restart_title","Reiniciar para aplicar"),
|
||||||
|
("lang.restart_body", "El idioma se aplicará la próxima vez que se inicie gr-client."),
|
||||||
|
];
|
||||||
|
|
||||||
|
const EN: Dict = &[
|
||||||
|
("win.title", "G Radio Client"),
|
||||||
|
("tab.player", "▶ Player"),
|
||||||
|
("tab.search", "🔍 Search"),
|
||||||
|
("tab.parrilla", "📅 Schedule"),
|
||||||
|
("tab.botonera", "🎛 Buttons"),
|
||||||
|
("tab.pautaje", "📋 Ad schedule"),
|
||||||
|
("tab.comercios", "📢 Ads"),
|
||||||
|
("tab.reportes", "📊 Reports"),
|
||||||
|
("btn.connect", "Connect"),
|
||||||
|
("btn.disconnect", "Disconnect"),
|
||||||
|
("btn.save", "Save"),
|
||||||
|
("btn.cancel", "Cancel"),
|
||||||
|
("btn.ok", "OK"),
|
||||||
|
("conn.server", "Server:"),
|
||||||
|
("conn.port", "Port:"),
|
||||||
|
("conn.token", "Token:"),
|
||||||
|
("conn.auto", "Connect automatically at startup"),
|
||||||
|
("conn.lan", "🖧 LAN"),
|
||||||
|
("conn.internet", "🌍 Internet (relay)"),
|
||||||
|
("conn.relay_id", "Relay ID (8 digits):"),
|
||||||
|
("conn.status_off", "● Disconnected"),
|
||||||
|
("conn.status_on", "● Connected"),
|
||||||
|
("conn.connecting", "○ Connecting…"),
|
||||||
|
("lang.label", "Language:"),
|
||||||
|
("lang.auto", "Automatic"),
|
||||||
|
("lang.restart_title","Restart to apply"),
|
||||||
|
("lang.restart_body", "The language change will take effect next time gr-client starts."),
|
||||||
|
];
|
||||||
|
|
||||||
|
const PT: Dict = &[
|
||||||
|
("win.title", "G Radio Client"),
|
||||||
|
("tab.player", "▶ Player"),
|
||||||
|
("tab.search", "🔍 Busca"),
|
||||||
|
("tab.parrilla", "📅 Grade"),
|
||||||
|
("tab.botonera", "🎛 Botoeira"),
|
||||||
|
("tab.pautaje", "📋 Pauta"),
|
||||||
|
("tab.comercios", "📢 Comerciais"),
|
||||||
|
("tab.reportes", "📊 Relatórios"),
|
||||||
|
("btn.connect", "Conectar"),
|
||||||
|
("btn.disconnect", "Desconectar"),
|
||||||
|
("btn.save", "Salvar"),
|
||||||
|
("btn.cancel", "Cancelar"),
|
||||||
|
("btn.ok", "OK"),
|
||||||
|
("conn.server", "Servidor:"),
|
||||||
|
("conn.port", "Porta:"),
|
||||||
|
("conn.token", "Token:"),
|
||||||
|
("conn.auto", "Conectar automaticamente ao iniciar"),
|
||||||
|
("conn.lan", "🖧 LAN"),
|
||||||
|
("conn.internet", "🌍 Internet (relay)"),
|
||||||
|
("conn.relay_id", "ID do relay (8 dígitos):"),
|
||||||
|
("conn.status_off", "● Desconectado"),
|
||||||
|
("conn.status_on", "● Conectado"),
|
||||||
|
("conn.connecting", "○ Conectando…"),
|
||||||
|
("lang.label", "Idioma:"),
|
||||||
|
("lang.auto", "Automático"),
|
||||||
|
("lang.restart_title","Reinicie para aplicar"),
|
||||||
|
("lang.restart_body", "O idioma será aplicado na próxima vez que iniciar o gr-client."),
|
||||||
|
];
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
// gr-client — Cliente remoto para G Radio Player
|
||||||
|
//
|
||||||
|
// Conecta a un servidor radio-player via TCP (puerto 7777 por defecto)
|
||||||
|
// y permite controlar el reproductor y editar la programación desde otra máquina.
|
||||||
|
//
|
||||||
|
// Arquitectura futura: soporte de relay via gradio.net para acceso desde internet.
|
||||||
|
|
||||||
|
mod protocol;
|
||||||
|
mod estado;
|
||||||
|
mod conexion;
|
||||||
|
mod i18n;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::gio::prelude::ApplicationExtManual;
|
||||||
|
|
||||||
|
fn main() -> glib::ExitCode {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
// i18n: leer locale forzado del config o autodetectar
|
||||||
|
let cfg = estado::ConfigCliente::cargar();
|
||||||
|
let lc = i18n::init(cfg.locale.as_deref());
|
||||||
|
eprintln!("[gr-client] locale = {}", lc.code());
|
||||||
|
|
||||||
|
let app = gtk4::Application::builder()
|
||||||
|
.application_id("com.gradio.client")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
app.connect_activate(|app: >k4::Application| {
|
||||||
|
// Evitar abrir segunda ventana si ya hay una activa
|
||||||
|
if let Some(w) = app.active_window() {
|
||||||
|
w.present();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui::ventana_principal::construir(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.run()
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
// protocol.rs — Tipos del protocolo cliente↔servidor gr-client
|
||||||
|
// Protocolo: JSON por línea sobre TCP (cada mensaje termina en '\n')
|
||||||
|
// El mismo archivo existe en radio-player/src/protocol.rs (deben ser idénticos)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ─── Tipos compartidos ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Una entrada de pautaje (comerciales/eventos)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EntradaRemota {
|
||||||
|
pub ruta: String,
|
||||||
|
pub dias: String, // "1234567"
|
||||||
|
pub inicio: String, // "YYYYMMDD"
|
||||||
|
pub fin: String, // "YYYYMMDD"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Una pista del playlist
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TrackRemoto {
|
||||||
|
pub ruta: String,
|
||||||
|
pub duracion: String, // "HH:MM:SS.mmm"
|
||||||
|
pub titulo: String, // basename sin extensión
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estado del reproductor en el servidor
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct EstadoRemoto {
|
||||||
|
pub reproduciendo: bool,
|
||||||
|
pub pausado: bool,
|
||||||
|
pub track_actual: String,
|
||||||
|
pub titulo_actual: String,
|
||||||
|
pub posicion_secs: f64,
|
||||||
|
pub duracion_secs: f64,
|
||||||
|
pub upvol: u8,
|
||||||
|
pub downvol: u8,
|
||||||
|
pub comerciales_activos: bool,
|
||||||
|
pub eventos_activos: bool,
|
||||||
|
pub num_comerciales: i32,
|
||||||
|
pub num_eventos: i32,
|
||||||
|
pub num_eventos_espera: i32,
|
||||||
|
pub indice_actual: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Una entrada del árbol de directorios remoto
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EntradaDir {
|
||||||
|
pub nombre: String,
|
||||||
|
pub ruta: String,
|
||||||
|
pub es_dir: bool,
|
||||||
|
pub es_audio: bool,
|
||||||
|
pub tiene_hijos: bool, // para lazy-loading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado de búsqueda de audio
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResultadoBusqueda {
|
||||||
|
pub ruta: String,
|
||||||
|
pub nombre: String,
|
||||||
|
pub duracion: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un botón de la botonera
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BotonRemoto {
|
||||||
|
pub indice: u8,
|
||||||
|
pub etiqueta: String,
|
||||||
|
pub ruta: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mensajes Cliente → Servidor ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "tipo")]
|
||||||
|
pub enum MsgCliente {
|
||||||
|
// Conexión
|
||||||
|
Auth { token: String },
|
||||||
|
Ping,
|
||||||
|
|
||||||
|
// Sistema de archivos remoto
|
||||||
|
ListarDir { ruta: String },
|
||||||
|
|
||||||
|
// Pautaje comerciales/eventos
|
||||||
|
ObtenerPautaje { tipo_p: String, hora: u8, minuto: u8 },
|
||||||
|
GuardarPautaje { tipo_p: String, hora: u8, minuto: u8, entradas: Vec<EntradaRemota> },
|
||||||
|
|
||||||
|
// Parrilla musical
|
||||||
|
ObtenerParrilla { dia: u8, hora: u8 },
|
||||||
|
GuardarParrilla { dia: u8, hora: u8, items: Vec<String> },
|
||||||
|
CopiarHoraParrilla { dia_orig: u8, hora_orig: u8, dia_dest: u8, hora_dest: u8 },
|
||||||
|
|
||||||
|
// Botonera
|
||||||
|
ObtenerBotonera,
|
||||||
|
GuardarBotonera { botones: Vec<BotonRemoto> },
|
||||||
|
|
||||||
|
// Player
|
||||||
|
ObtenerPlaylist,
|
||||||
|
ObtenerEstado,
|
||||||
|
ComandoPlayer { cmd: String }, // "play","pause","stop","stop_after","siguiente"
|
||||||
|
SetVolumen { upvol: u8 },
|
||||||
|
ReproducirAhora { ruta: String },
|
||||||
|
AgregarAlPlaylist { ruta: String, duracion: String },
|
||||||
|
EliminarDePlaylist { indice: usize },
|
||||||
|
MoverEnPlaylist { indice: usize, arriba: bool },
|
||||||
|
|
||||||
|
// Búsqueda
|
||||||
|
Buscar { consulta: String, ruta: Option<String> },
|
||||||
|
|
||||||
|
// Reportes / visor
|
||||||
|
ObtenerReportes { tipo_r: String, fecha: String },
|
||||||
|
|
||||||
|
// Info de audio
|
||||||
|
InfoAudio { ruta: String },
|
||||||
|
|
||||||
|
// Índice locate
|
||||||
|
ActualizarIndice,
|
||||||
|
|
||||||
|
// Control de emergencia
|
||||||
|
StopGeneral,
|
||||||
|
VaciarComerciales,
|
||||||
|
VaciarEventos,
|
||||||
|
VaciarEventosEspera,
|
||||||
|
|
||||||
|
// Consulta y edición de colas activas
|
||||||
|
ObtenerCola { tipo_cola: String },
|
||||||
|
EliminarDeCola { tipo_cola: String, indice: usize },
|
||||||
|
|
||||||
|
// Playlist play now (desde cliente remoto)
|
||||||
|
PlaylistPlayAhora { ruta: String, duracion: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mensajes Servidor → Cliente ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "tipo")]
|
||||||
|
pub enum MsgServidor {
|
||||||
|
// Conexión
|
||||||
|
AuthOk { version: String },
|
||||||
|
Pong,
|
||||||
|
Error { mensaje: String },
|
||||||
|
|
||||||
|
// Sistema de archivos
|
||||||
|
DirectorioListado { ruta: String, entradas: Vec<EntradaDir> },
|
||||||
|
|
||||||
|
// Pautaje
|
||||||
|
PautajeObtenido { tipo_p: String, hora: u8, minuto: u8, entradas: Vec<EntradaRemota> },
|
||||||
|
PautajeGuardado,
|
||||||
|
|
||||||
|
// Parrilla
|
||||||
|
ParrillaObtenida { dia: u8, hora: u8, items: Vec<String> },
|
||||||
|
ParrillaGuardada,
|
||||||
|
|
||||||
|
// Botonera
|
||||||
|
BotoneraObtenida { botones: Vec<BotonRemoto> },
|
||||||
|
BotoneraGuardada,
|
||||||
|
|
||||||
|
// Player
|
||||||
|
PlaylistObtenida { tracks: Vec<TrackRemoto> },
|
||||||
|
EstadoObtenido { estado: EstadoRemoto },
|
||||||
|
InfoAudioObtenida { ruta: String, duracion: String },
|
||||||
|
|
||||||
|
// Búsqueda
|
||||||
|
ResultadosBusqueda { resultados: Vec<ResultadoBusqueda> },
|
||||||
|
|
||||||
|
// Reportes
|
||||||
|
ReportesObtenidos { lineas: Vec<String> },
|
||||||
|
|
||||||
|
// Push — el servidor los envía sin solicitud previa
|
||||||
|
PlaylistCambiada { tracks: Vec<TrackRemoto> },
|
||||||
|
EstadoCambiado { estado: EstadoRemoto },
|
||||||
|
IndiceActualizado { ok: bool, mensaje: String },
|
||||||
|
|
||||||
|
// Colas activas
|
||||||
|
ColaObtenida { tipo_cola: String, items: Vec<String> },
|
||||||
|
|
||||||
|
Ok,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod ventana_principal;
|
||||||
|
pub mod panel_conexion;
|
||||||
|
pub mod panel_player;
|
||||||
|
pub mod panel_pautaje;
|
||||||
|
pub mod panel_parrilla;
|
||||||
|
pub mod panel_buscador;
|
||||||
|
pub mod panel_visor;
|
||||||
|
pub mod panel_botonera;
|
||||||
|
pub mod panel_comercios;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// panel_botonera.rs — Botonera remota
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Align, Box as GtkBox, Button, Entry, FlowBox, Label, Orientation, ScrolledWindow, Separator,
|
||||||
|
};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::protocol::{BotonRemoto, MsgCliente};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelBotonera {
|
||||||
|
pub root: GtkBox,
|
||||||
|
flow: FlowBox,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
botones: Rc<RefCell<Vec<BotonRemoto>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelBotonera {
|
||||||
|
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 4);
|
||||||
|
root.set_margin_top(6);
|
||||||
|
root.set_margin_bottom(6);
|
||||||
|
root.set_margin_start(6);
|
||||||
|
root.set_margin_end(6);
|
||||||
|
|
||||||
|
let toolbar = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
let btn_cargar = Button::with_label("⟳ Cargar");
|
||||||
|
let btn_guardar = Button::with_label("💾 Guardar");
|
||||||
|
btn_guardar.add_css_class("suggested-action");
|
||||||
|
toolbar.append(&btn_cargar);
|
||||||
|
toolbar.append(&btn_guardar);
|
||||||
|
root.append(&toolbar);
|
||||||
|
root.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
let lbl = Label::new(Some(
|
||||||
|
"Clic → reproducir en servidor. Clic derecho → editar botón.",
|
||||||
|
));
|
||||||
|
lbl.add_css_class("dim-label");
|
||||||
|
lbl.set_margin_top(8);
|
||||||
|
lbl.set_margin_bottom(8);
|
||||||
|
root.append(&lbl);
|
||||||
|
|
||||||
|
let flow = FlowBox::new();
|
||||||
|
flow.set_max_children_per_line(8);
|
||||||
|
flow.set_min_children_per_line(4);
|
||||||
|
flow.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
flow.set_homogeneous(true);
|
||||||
|
flow.set_row_spacing(4);
|
||||||
|
flow.set_column_spacing(4);
|
||||||
|
flow.set_margin_top(8);
|
||||||
|
flow.set_margin_bottom(8);
|
||||||
|
flow.set_margin_start(8);
|
||||||
|
flow.set_margin_end(8);
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&flow));
|
||||||
|
root.append(&scroll);
|
||||||
|
|
||||||
|
let botones: Rc<RefCell<Vec<BotonRemoto>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|
||||||
|
{
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
btn_cargar.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerBotonera);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let botones2 = botones.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
btn_guardar.connect_clicked(move |_| {
|
||||||
|
let lista = botones2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::GuardarBotonera { botones: lista });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelBotonera { root, flow, conexion: conexion.clone(), botones }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargar_botones(&self, botones: &[BotonRemoto]) {
|
||||||
|
while let Some(child) = self.flow.first_child() {
|
||||||
|
self.flow.remove(&child);
|
||||||
|
}
|
||||||
|
*self.botones.borrow_mut() = botones.to_vec();
|
||||||
|
|
||||||
|
for boton in botones {
|
||||||
|
let btn = Button::with_label(&boton.etiqueta);
|
||||||
|
btn.set_size_request(90, 60);
|
||||||
|
btn.add_css_class("botonera-btn");
|
||||||
|
|
||||||
|
let ruta = boton.ruta.clone();
|
||||||
|
let conexion2 = self.conexion.clone();
|
||||||
|
btn.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ReproducirAhora { ruta: ruta.clone() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clic derecho → editar
|
||||||
|
let gc = gtk4::GestureClick::new();
|
||||||
|
gc.set_button(3);
|
||||||
|
let btn2 = btn.clone();
|
||||||
|
let indice = boton.indice;
|
||||||
|
let botones2 = self.botones.clone();
|
||||||
|
gc.connect_pressed(move |_gc, _n, _x, _y| {
|
||||||
|
let padre: Option<gtk4::Window> = btn2
|
||||||
|
.root()
|
||||||
|
.and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||||
|
abrir_dialogo_editar_boton(
|
||||||
|
padre.as_ref(),
|
||||||
|
&btn2.label().unwrap_or_default(),
|
||||||
|
{
|
||||||
|
let b = botones2.borrow();
|
||||||
|
b.iter().find(|b| b.indice == indice)
|
||||||
|
.map(|b| b.ruta.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let btn3 = btn2.clone();
|
||||||
|
let b2 = botones2.clone();
|
||||||
|
move |nueva_lbl, nueva_ruta| {
|
||||||
|
btn3.set_label(&nueva_lbl);
|
||||||
|
if let Some(b) = b2.borrow_mut().iter_mut().find(|b| b.indice == indice) {
|
||||||
|
b.etiqueta = nueva_lbl;
|
||||||
|
b.ruta = nueva_ruta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
btn.add_controller(gc);
|
||||||
|
|
||||||
|
self.flow.insert(&btn, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
self.flow.set_sensitive(activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abrir_dialogo_editar_boton<F: Fn(String, String) + 'static>(
|
||||||
|
padre: Option<>k4::Window>,
|
||||||
|
etiqueta_actual: &str,
|
||||||
|
ruta_actual: String,
|
||||||
|
on_accept: F,
|
||||||
|
) {
|
||||||
|
let win = gtk4::Window::new();
|
||||||
|
win.set_title(Some("Editar botón"));
|
||||||
|
win.set_modal(true);
|
||||||
|
win.set_resizable(false);
|
||||||
|
if let Some(p) = padre { win.set_transient_for(Some(p)); }
|
||||||
|
|
||||||
|
let vbox = GtkBox::new(Orientation::Vertical, 8);
|
||||||
|
vbox.set_margin_top(12);
|
||||||
|
vbox.set_margin_bottom(12);
|
||||||
|
vbox.set_margin_start(16);
|
||||||
|
vbox.set_margin_end(16);
|
||||||
|
|
||||||
|
let f1 = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
f1.append(&Label::new(Some("Etiqueta:")));
|
||||||
|
let e_lbl = Entry::new();
|
||||||
|
e_lbl.set_text(etiqueta_actual);
|
||||||
|
e_lbl.set_hexpand(true);
|
||||||
|
f1.append(&e_lbl);
|
||||||
|
vbox.append(&f1);
|
||||||
|
|
||||||
|
let f2 = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
f2.append(&Label::new(Some("Ruta:")));
|
||||||
|
let e_ruta = Entry::new();
|
||||||
|
e_ruta.set_text(&ruta_actual);
|
||||||
|
e_ruta.set_hexpand(true);
|
||||||
|
f2.append(&e_ruta);
|
||||||
|
vbox.append(&f2);
|
||||||
|
|
||||||
|
let fila_btn = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_btn.set_halign(Align::End);
|
||||||
|
fila_btn.set_margin_top(8);
|
||||||
|
let btn_cancel = Button::with_label("Cancelar");
|
||||||
|
let btn_ok = Button::with_label("Aceptar");
|
||||||
|
btn_ok.add_css_class("suggested-action");
|
||||||
|
fila_btn.append(&btn_cancel);
|
||||||
|
fila_btn.append(&btn_ok);
|
||||||
|
vbox.append(&fila_btn);
|
||||||
|
|
||||||
|
win.set_child(Some(&vbox));
|
||||||
|
let on_accept = Rc::new(on_accept);
|
||||||
|
{
|
||||||
|
let win2 = win.clone();
|
||||||
|
let e_lbl2 = e_lbl.clone();
|
||||||
|
let e_ruta2 = e_ruta.clone();
|
||||||
|
let oa2 = on_accept.clone();
|
||||||
|
btn_ok.connect_clicked(move |_| {
|
||||||
|
on_accept(e_lbl2.text().to_string(), e_ruta2.text().to_string());
|
||||||
|
win2.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let win2 = win.clone();
|
||||||
|
btn_cancel.connect_clicked(move |_| { win2.close(); });
|
||||||
|
}
|
||||||
|
win.present();
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// panel_buscador.rs — Buscador de audio remoto
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Box as GtkBox, Button, DragSource, Entry, Label, ListStore,
|
||||||
|
Orientation, ScrolledWindow, SelectionMode, Separator,
|
||||||
|
TreeView, TreeViewColumn, CellRendererText,
|
||||||
|
};
|
||||||
|
use gdk4::ContentProvider;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::protocol::{MsgCliente, ResultadoBusqueda};
|
||||||
|
|
||||||
|
const COL_NOMBRE: u32 = 0;
|
||||||
|
const COL_DURACION: u32 = 1;
|
||||||
|
const COL_RUTA: u32 = 2;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelBuscador {
|
||||||
|
pub root: GtkBox,
|
||||||
|
entry_busqueda: Entry,
|
||||||
|
entry_ruta: Entry,
|
||||||
|
store: ListStore,
|
||||||
|
tree: TreeView,
|
||||||
|
lbl_resultados: Label,
|
||||||
|
btn_actualizar: Button,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelBuscador {
|
||||||
|
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 4);
|
||||||
|
root.set_margin_top(6);
|
||||||
|
root.set_margin_bottom(6);
|
||||||
|
root.set_margin_start(6);
|
||||||
|
root.set_margin_end(6);
|
||||||
|
|
||||||
|
// Fila de búsqueda
|
||||||
|
let fila_b = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
fila_b.append(&Label::new(Some("Buscar:")));
|
||||||
|
let entry_busqueda = Entry::new();
|
||||||
|
entry_busqueda.set_hexpand(true);
|
||||||
|
entry_busqueda.set_placeholder_text(Some("nombre del archivo…"));
|
||||||
|
fila_b.append(&entry_busqueda);
|
||||||
|
let btn_buscar = Button::with_label("🔍 Buscar");
|
||||||
|
btn_buscar.add_css_class("suggested-action");
|
||||||
|
fila_b.append(&btn_buscar);
|
||||||
|
root.append(&fila_b);
|
||||||
|
|
||||||
|
// Fila de ruta opcional + botón actualizar índice
|
||||||
|
let fila_r = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
fila_r.append(&Label::new(Some("En ruta:")));
|
||||||
|
let entry_ruta = Entry::new();
|
||||||
|
entry_ruta.set_hexpand(true);
|
||||||
|
entry_ruta.set_placeholder_text(Some("(vacío = todo el servidor)"));
|
||||||
|
fila_r.append(&entry_ruta);
|
||||||
|
let btn_actualizar = Button::with_label("⟳ Índice");
|
||||||
|
btn_actualizar.set_tooltip_text(Some("Actualizar índice de locate en el servidor (updatedb)"));
|
||||||
|
fila_r.append(&btn_actualizar);
|
||||||
|
root.append(&fila_r);
|
||||||
|
|
||||||
|
let lbl_resultados = Label::new(Some("Sin resultados"));
|
||||||
|
lbl_resultados.add_css_class("dim-label");
|
||||||
|
lbl_resultados.set_halign(gtk4::Align::Start);
|
||||||
|
lbl_resultados.set_margin_top(4);
|
||||||
|
root.append(&lbl_resultados);
|
||||||
|
root.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
// Lista
|
||||||
|
let store = ListStore::new(&[
|
||||||
|
glib::Type::STRING, glib::Type::STRING, glib::Type::STRING,
|
||||||
|
]);
|
||||||
|
let tree = TreeView::with_model(&store);
|
||||||
|
tree.set_headers_visible(true);
|
||||||
|
tree.selection().set_mode(SelectionMode::Multiple);
|
||||||
|
tree.set_hexpand(true);
|
||||||
|
tree.set_vexpand(true);
|
||||||
|
|
||||||
|
for (titulo, col, expand) in [
|
||||||
|
("Nombre", COL_NOMBRE, true),
|
||||||
|
("Duración", COL_DURACION, false),
|
||||||
|
("Ruta", COL_RUTA, false),
|
||||||
|
] {
|
||||||
|
let c = TreeViewColumn::new();
|
||||||
|
c.set_title(titulo);
|
||||||
|
c.set_expand(expand);
|
||||||
|
if !expand { c.set_min_width(80); }
|
||||||
|
let cr = CellRendererText::new();
|
||||||
|
c.pack_start(&cr, expand);
|
||||||
|
c.add_attribute(&cr, "text", col as i32);
|
||||||
|
tree.append_column(&c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag source — arrastra la ruta como STRING
|
||||||
|
let drag_src = DragSource::new();
|
||||||
|
drag_src.set_actions(gdk4::DragAction::COPY);
|
||||||
|
{
|
||||||
|
let tree2 = tree.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
drag_src.connect_prepare(move |_src, x, y| {
|
||||||
|
let Some((Some(path), ..)) = tree2.path_at_pos(x as i32, y as i32) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let iter = store2.iter(&path)?;
|
||||||
|
let ruta: String = store2
|
||||||
|
.get_value(&iter, COL_RUTA as i32)
|
||||||
|
.get()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(ContentProvider::for_value(&ruta.to_value()))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tree.add_controller(drag_src);
|
||||||
|
|
||||||
|
// Menú contextual (botón derecho)
|
||||||
|
let gc = gtk4::GestureClick::new();
|
||||||
|
gc.set_button(3);
|
||||||
|
{
|
||||||
|
let tree2 = tree.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
gc.connect_pressed(move |_gc, _n, x, y| {
|
||||||
|
let Some((Some(path), ..)) = tree2.path_at_pos(x as i32, y as i32) else { return; };
|
||||||
|
tree2.selection().select_path(&path);
|
||||||
|
let Some(iter) = store2.iter(&path) else { return; };
|
||||||
|
let ruta: String = store2.get_value(&iter, COL_RUTA as i32).get().unwrap_or_default();
|
||||||
|
let duracion: String = store2.get_value(&iter, COL_DURACION as i32).get().unwrap_or_default();
|
||||||
|
|
||||||
|
let menu = gtk4::gio::Menu::new();
|
||||||
|
menu.append(Some("▶ Reproducir ahora"), Some("busq.play-now"));
|
||||||
|
menu.append(Some("+ Agregar al playlist"), Some("busq.add-playlist"));
|
||||||
|
menu.append(Some("📋 Copiar ruta"), Some("busq.copiar-ruta"));
|
||||||
|
|
||||||
|
let ag = gtk4::gio::SimpleActionGroup::new();
|
||||||
|
{
|
||||||
|
let r = ruta.clone(); let c = conexion2.clone();
|
||||||
|
let a = gtk4::gio::SimpleAction::new("play-now", None);
|
||||||
|
a.connect_activate(move |_, _| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::ReproducirAhora { ruta: r.clone() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ag.add_action(&a);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let r = ruta.clone(); let d = duracion.clone(); let c = conexion2.clone();
|
||||||
|
let a = gtk4::gio::SimpleAction::new("add-playlist", None);
|
||||||
|
a.connect_activate(move |_, _| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::AgregarAlPlaylist {
|
||||||
|
ruta: r.clone(), duracion: d.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ag.add_action(&a);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let r = ruta.clone();
|
||||||
|
let tree3 = tree2.clone();
|
||||||
|
let a = gtk4::gio::SimpleAction::new("copiar-ruta", None);
|
||||||
|
a.connect_activate(move |_, _| {
|
||||||
|
if let Some(display) = tree3.display().downcast_ref::<gdk4::Display>() {
|
||||||
|
display.clipboard().set_text(&r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ag.add_action(&a);
|
||||||
|
}
|
||||||
|
tree2.insert_action_group("busq", Some(&ag));
|
||||||
|
let popover = gtk4::PopoverMenu::from_model(Some(&menu));
|
||||||
|
popover.set_parent(&tree2);
|
||||||
|
popover.popup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tree.add_controller(gc);
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&tree));
|
||||||
|
root.append(&scroll);
|
||||||
|
|
||||||
|
// Señal: actualizar índice
|
||||||
|
{
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
let lbl2 = lbl_resultados.clone();
|
||||||
|
let btn2 = btn_actualizar.clone();
|
||||||
|
btn_actualizar.connect_clicked(move |_| {
|
||||||
|
lbl2.set_text("Actualizando índice…");
|
||||||
|
btn2.set_sensitive(false);
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ActualizarIndice);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar al presionar Enter o botón
|
||||||
|
{
|
||||||
|
let entry_b2 = entry_busqueda.clone();
|
||||||
|
let entry_r2 = entry_ruta.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
let lbl2 = lbl_resultados.clone();
|
||||||
|
let buscar = move || {
|
||||||
|
let consulta = entry_b2.text().to_string();
|
||||||
|
if consulta.is_empty() { return; }
|
||||||
|
let ruta = {
|
||||||
|
let r = entry_r2.text().to_string();
|
||||||
|
if r.is_empty() { None } else { Some(r) }
|
||||||
|
};
|
||||||
|
lbl2.set_text("Buscando…");
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::Buscar { consulta, ruta });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let b2 = buscar.clone();
|
||||||
|
btn_buscar.connect_clicked(move |_| b2());
|
||||||
|
entry_busqueda.connect_activate(move |_| buscar());
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelBuscador {
|
||||||
|
root, entry_busqueda, entry_ruta, store, tree,
|
||||||
|
lbl_resultados, btn_actualizar,
|
||||||
|
conexion: conexion.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargar_resultados(&self, resultados: &[ResultadoBusqueda]) {
|
||||||
|
self.store.clear();
|
||||||
|
for r in resultados {
|
||||||
|
self.store.insert_with_values(None, &[
|
||||||
|
(COL_NOMBRE, &r.nombre),
|
||||||
|
(COL_DURACION, &r.duracion),
|
||||||
|
(COL_RUTA, &r.ruta),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let n = resultados.len();
|
||||||
|
self.lbl_resultados.set_text(&match n {
|
||||||
|
0 => "Sin resultados".to_string(),
|
||||||
|
1 => "1 resultado".to_string(),
|
||||||
|
_ => format!("{} resultados", n),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mostrar_resultado_indice(&self, ok: bool, mensaje: &str) {
|
||||||
|
self.btn_actualizar.set_sensitive(true);
|
||||||
|
self.lbl_resultados.set_text(if ok { "Índice actualizado" } else { mensaje });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
self.entry_busqueda.set_sensitive(activo);
|
||||||
|
self.entry_ruta.set_sensitive(activo);
|
||||||
|
self.btn_actualizar.set_sensitive(activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// panel_comercios.rs — Control de colas activas: comerciales, eventos, eventos en espera
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Box as GtkBox, Button, Frame, Label, ListBox, ListBoxRow,
|
||||||
|
Orientation, ScrolledWindow, Separator,
|
||||||
|
};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::protocol::MsgCliente;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelComercial {
|
||||||
|
pub root: GtkBox,
|
||||||
|
// Listas
|
||||||
|
list_comerciales: ListBox,
|
||||||
|
list_eventos: ListBox,
|
||||||
|
list_espera: ListBox,
|
||||||
|
// Botones de acción por sección
|
||||||
|
btn_stop_com: Button,
|
||||||
|
btn_vaciar_com: Button,
|
||||||
|
btn_stop_ev: Button,
|
||||||
|
btn_vaciar_ev: Button,
|
||||||
|
btn_vaciar_esp: Button,
|
||||||
|
// Contadores en el frame label
|
||||||
|
frame_com: Frame,
|
||||||
|
frame_ev: Frame,
|
||||||
|
frame_esp: Frame,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelComercial {
|
||||||
|
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 6);
|
||||||
|
root.set_margin_top(8);
|
||||||
|
root.set_margin_bottom(8);
|
||||||
|
root.set_margin_start(8);
|
||||||
|
root.set_margin_end(8);
|
||||||
|
|
||||||
|
// ── Botón Stop General ────────────────────────────────────────────────
|
||||||
|
let btn_stop_general = Button::with_label("⏹ Stop General — detiene toda reproducción");
|
||||||
|
btn_stop_general.add_css_class("destructive-action");
|
||||||
|
btn_stop_general.set_margin_bottom(4);
|
||||||
|
root.append(&btn_stop_general);
|
||||||
|
root.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
// ── Sección Comerciales ───────────────────────────────────────────────
|
||||||
|
let (frame_com, list_comerciales, btn_stop_com, btn_vaciar_com) =
|
||||||
|
construir_seccion("📢 Comerciales", true);
|
||||||
|
root.append(&frame_com);
|
||||||
|
|
||||||
|
// ── Sección Eventos ───────────────────────────────────────────────────
|
||||||
|
let (frame_ev, list_eventos, btn_stop_ev, btn_vaciar_ev) =
|
||||||
|
construir_seccion("📅 Eventos", true);
|
||||||
|
root.append(&frame_ev);
|
||||||
|
|
||||||
|
// ── Sección Eventos en espera ─────────────────────────────────────────
|
||||||
|
let (frame_esp, list_espera, _, btn_vaciar_esp) =
|
||||||
|
construir_seccion("⏳ Eventos en espera", false);
|
||||||
|
root.append(&frame_esp);
|
||||||
|
|
||||||
|
// ── Botón Recargar ────────────────────────────────────────────────────
|
||||||
|
let btn_recargar = Button::with_label("↻ Recargar colas");
|
||||||
|
btn_recargar.set_margin_top(4);
|
||||||
|
root.append(&btn_recargar);
|
||||||
|
|
||||||
|
// ── Señales ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Stop General
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_stop_general.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::StopGeneral);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop Comercial
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_stop_com.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::StopGeneral);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vaciar Comerciales
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_vaciar_com.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::VaciarComerciales);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop Evento
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_stop_ev.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::StopGeneral);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vaciar Eventos
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_vaciar_ev.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::VaciarEventos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vaciar Eventos en espera
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_vaciar_esp.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::VaciarEventosEspera);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recargar
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_recargar.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerCola { tipo_cola: "comerciales".into() });
|
||||||
|
con.enviar(MsgCliente::ObtenerCola { tipo_cola: "eventos".into() });
|
||||||
|
con.enviar(MsgCliente::ObtenerCola { tipo_cola: "eventos_espera".into() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelComercial {
|
||||||
|
root,
|
||||||
|
list_comerciales, list_eventos, list_espera,
|
||||||
|
btn_stop_com, btn_vaciar_com,
|
||||||
|
btn_stop_ev, btn_vaciar_ev,
|
||||||
|
btn_vaciar_esp,
|
||||||
|
frame_com, frame_ev, frame_esp,
|
||||||
|
conexion: conexion.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actualiza una de las tres colas cuando llega ColaObtenida del servidor
|
||||||
|
pub fn actualizar_cola(&self, tipo_cola: &str, items: &[String]) {
|
||||||
|
let (list, frame, tiene_stop) = match tipo_cola {
|
||||||
|
"eventos" => (&self.list_eventos, &self.frame_ev, true),
|
||||||
|
"eventos_espera" => (&self.list_espera, &self.frame_esp, false),
|
||||||
|
_ => (&self.list_comerciales, &self.frame_com, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar etiqueta del frame con el conteo
|
||||||
|
let label_base = match tipo_cola {
|
||||||
|
"eventos" => "📅 Eventos",
|
||||||
|
"eventos_espera" => "⏳ Eventos en espera",
|
||||||
|
_ => "📢 Comerciales",
|
||||||
|
};
|
||||||
|
let label = if items.is_empty() {
|
||||||
|
format!("{} — vacía", label_base)
|
||||||
|
} else {
|
||||||
|
format!("{} — {} en cola", label_base, items.len())
|
||||||
|
};
|
||||||
|
frame.set_label(Some(&label));
|
||||||
|
|
||||||
|
// Actualizar botones según haya items
|
||||||
|
match tipo_cola {
|
||||||
|
"eventos" => {
|
||||||
|
self.btn_stop_ev.set_sensitive(!items.is_empty());
|
||||||
|
self.btn_vaciar_ev.set_sensitive(!items.is_empty());
|
||||||
|
}
|
||||||
|
"eventos_espera" => {
|
||||||
|
self.btn_vaciar_esp.set_sensitive(!items.is_empty());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.btn_stop_com.set_sensitive(!items.is_empty());
|
||||||
|
self.btn_vaciar_com.set_sensitive(!items.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruir la lista
|
||||||
|
while let Some(child) = list.first_child() {
|
||||||
|
list.remove(&child);
|
||||||
|
}
|
||||||
|
if items.is_empty() {
|
||||||
|
let lbl = Label::new(Some("Cola vacía"));
|
||||||
|
lbl.add_css_class("dim-label");
|
||||||
|
lbl.set_margin_top(6);
|
||||||
|
lbl.set_margin_bottom(6);
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
row.set_child(Some(&lbl));
|
||||||
|
row.set_activatable(false);
|
||||||
|
row.set_selectable(false);
|
||||||
|
list.append(&row);
|
||||||
|
} else {
|
||||||
|
for (idx, ruta) in items.iter().enumerate() {
|
||||||
|
let es_primero = idx == 0;
|
||||||
|
let row = crear_fila_cola(idx, ruta, es_primero, tipo_cola, &self.conexion);
|
||||||
|
list.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
self.root.set_sensitive(activo);
|
||||||
|
if activo {
|
||||||
|
// Pedir las tres colas al activar
|
||||||
|
if let Some(ref con) = *self.conexion.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerCola { tipo_cola: "comerciales".into() });
|
||||||
|
con.enviar(MsgCliente::ObtenerCola { tipo_cola: "eventos".into() });
|
||||||
|
con.enviar(MsgCliente::ObtenerCola { tipo_cola: "eventos_espera".into() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construye una sección (frame + listbox + botones) ────────────────────────
|
||||||
|
|
||||||
|
fn construir_seccion(
|
||||||
|
titulo: &str,
|
||||||
|
con_stop: bool,
|
||||||
|
) -> (Frame, ListBox, Button, Button) {
|
||||||
|
let frame = Frame::new(Some(titulo));
|
||||||
|
frame.set_vexpand(true);
|
||||||
|
|
||||||
|
let vbox = GtkBox::new(Orientation::Vertical, 0);
|
||||||
|
|
||||||
|
// Botones de acción en el header de la sección
|
||||||
|
let btn_row = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
btn_row.set_margin_start(6);
|
||||||
|
btn_row.set_margin_end(6);
|
||||||
|
btn_row.set_margin_top(4);
|
||||||
|
btn_row.set_margin_bottom(4);
|
||||||
|
|
||||||
|
let btn_stop = Button::with_label("⏹ Detener");
|
||||||
|
let btn_vaciar = Button::with_label("🗑 Vaciar todo");
|
||||||
|
|
||||||
|
btn_stop.add_css_class("com-stop-btn");
|
||||||
|
btn_vaciar.add_css_class("destructive-action");
|
||||||
|
btn_vaciar.add_css_class("com-vaciar-btn");
|
||||||
|
|
||||||
|
if con_stop {
|
||||||
|
btn_row.append(&btn_stop);
|
||||||
|
}
|
||||||
|
btn_row.append(&btn_vaciar);
|
||||||
|
vbox.append(&btn_row);
|
||||||
|
vbox.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
// Lista de ítems
|
||||||
|
let list = ListBox::new();
|
||||||
|
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
list.add_css_class("comercios-list");
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_min_content_height(60);
|
||||||
|
scroll.set_child(Some(&list));
|
||||||
|
vbox.append(&scroll);
|
||||||
|
|
||||||
|
frame.set_child(Some(&vbox));
|
||||||
|
|
||||||
|
// Inicializar con "cola vacía"
|
||||||
|
let lbl = Label::new(Some("Cola vacía"));
|
||||||
|
lbl.add_css_class("dim-label");
|
||||||
|
lbl.set_margin_top(6);
|
||||||
|
lbl.set_margin_bottom(6);
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
row.set_child(Some(&lbl));
|
||||||
|
row.set_activatable(false);
|
||||||
|
row.set_selectable(false);
|
||||||
|
list.append(&row);
|
||||||
|
|
||||||
|
(frame, list, btn_stop, btn_vaciar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crea una fila de ítem de cola con botón eliminar ─────────────────────────
|
||||||
|
|
||||||
|
fn crear_fila_cola(
|
||||||
|
indice: usize,
|
||||||
|
ruta: &str,
|
||||||
|
es_primero: bool,
|
||||||
|
tipo_cola: &str,
|
||||||
|
conexion: &Rc<RefCell<Option<Conexion>>>,
|
||||||
|
) -> ListBoxRow {
|
||||||
|
let hbox = GtkBox::new(Orientation::Horizontal, 4);
|
||||||
|
hbox.set_margin_start(4);
|
||||||
|
hbox.set_margin_end(4);
|
||||||
|
hbox.set_margin_top(2);
|
||||||
|
hbox.set_margin_bottom(2);
|
||||||
|
|
||||||
|
// Indicador ▶ para el primero (en reproducción o siguiente)
|
||||||
|
let lbl_icono = Label::new(Some(if es_primero { "▶" } else { "" }));
|
||||||
|
lbl_icono.set_width_chars(2);
|
||||||
|
if es_primero { lbl_icono.add_css_class("estado-conectado"); }
|
||||||
|
hbox.append(&lbl_icono);
|
||||||
|
|
||||||
|
// Número
|
||||||
|
let lbl_num = Label::new(Some(&(indice + 1).to_string()));
|
||||||
|
lbl_num.set_width_chars(3);
|
||||||
|
lbl_num.set_halign(gtk4::Align::End);
|
||||||
|
lbl_num.add_css_class("dim-label");
|
||||||
|
hbox.append(&lbl_num);
|
||||||
|
|
||||||
|
// Nombre del archivo (sin extensión)
|
||||||
|
let nombre = Path::new(ruta)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(ruta)
|
||||||
|
.to_string();
|
||||||
|
let lbl_nombre = Label::new(Some(&nombre));
|
||||||
|
lbl_nombre.set_hexpand(true);
|
||||||
|
lbl_nombre.set_halign(gtk4::Align::Start);
|
||||||
|
lbl_nombre.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
||||||
|
hbox.append(&lbl_nombre);
|
||||||
|
|
||||||
|
// Botón eliminar
|
||||||
|
let btn_del = Button::with_label("✗");
|
||||||
|
btn_del.add_css_class("pl-row-btn");
|
||||||
|
btn_del.set_tooltip_text(Some("Eliminar de la cola"));
|
||||||
|
hbox.append(&btn_del);
|
||||||
|
|
||||||
|
let tipo_cola_s = tipo_cola.to_string();
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_del.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::EliminarDeCola {
|
||||||
|
tipo_cola: tipo_cola_s.clone(),
|
||||||
|
indice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
row.set_child(Some(&hbox));
|
||||||
|
row.set_activatable(false);
|
||||||
|
row.set_selectable(false);
|
||||||
|
row
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// panel_conexion.rs — Barra de conexión al servidor
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GtkBox, Button, CheckButton, ComboBoxText, Entry, Label, Orientation, Separator, Spinner};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::EstadoCon;
|
||||||
|
use crate::estado::EstadoCliente;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelConexion {
|
||||||
|
pub root: GtkBox,
|
||||||
|
// Modo LAN
|
||||||
|
pub entrada_servidor: Entry,
|
||||||
|
pub entrada_puerto: Entry,
|
||||||
|
// Modo relay
|
||||||
|
pub entrada_relay_id: Entry,
|
||||||
|
// Compartidos
|
||||||
|
pub entrada_token: Entry,
|
||||||
|
pub btn_conectar: Button,
|
||||||
|
pub check_relay: CheckButton,
|
||||||
|
pub combo_locale: ComboBoxText,
|
||||||
|
lbl_estado: Label,
|
||||||
|
spinner: Spinner,
|
||||||
|
// Contenedor de widgets LAN (para mostrar/ocultar)
|
||||||
|
box_lan: GtkBox,
|
||||||
|
box_relay: GtkBox,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelConexion {
|
||||||
|
pub fn nuevo(estado: &Rc<RefCell<EstadoCliente>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
root.add_css_class("panel-conexion");
|
||||||
|
root.set_margin_start(8);
|
||||||
|
root.set_margin_end(8);
|
||||||
|
root.set_margin_top(4);
|
||||||
|
root.set_margin_bottom(4);
|
||||||
|
|
||||||
|
let cfg = estado.borrow().config.clone();
|
||||||
|
|
||||||
|
// ── Toggle LAN / Internet ─────────────────────────────────────────────
|
||||||
|
let check_relay = CheckButton::with_label("Internet");
|
||||||
|
check_relay.set_active(cfg.modo_relay);
|
||||||
|
check_relay.set_tooltip_text(Some(
|
||||||
|
"Activar para conectarse vía relay.gradio.net (acceso desde internet)"
|
||||||
|
));
|
||||||
|
root.append(&check_relay);
|
||||||
|
root.append(&Separator::new(Orientation::Vertical));
|
||||||
|
|
||||||
|
// ── Widgets modo LAN ──────────────────────────────────────────────────
|
||||||
|
let box_lan = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
|
||||||
|
let lbl_srv = Label::new(Some(crate::i18n::tr("conn.server")));
|
||||||
|
lbl_srv.add_css_class("dim-label");
|
||||||
|
box_lan.append(&lbl_srv);
|
||||||
|
|
||||||
|
let entrada_servidor = Entry::new();
|
||||||
|
entrada_servidor.set_width_chars(18);
|
||||||
|
entrada_servidor.set_placeholder_text(Some("192.168.1.100"));
|
||||||
|
entrada_servidor.set_text(&cfg.servidor);
|
||||||
|
box_lan.append(&entrada_servidor);
|
||||||
|
|
||||||
|
box_lan.append(&Label::new(Some(":")));
|
||||||
|
|
||||||
|
let entrada_puerto = Entry::new();
|
||||||
|
entrada_puerto.set_width_chars(6);
|
||||||
|
entrada_puerto.set_text(&cfg.puerto.to_string());
|
||||||
|
box_lan.append(&entrada_puerto);
|
||||||
|
|
||||||
|
root.append(&box_lan);
|
||||||
|
|
||||||
|
// ── Widgets modo relay ────────────────────────────────────────────────
|
||||||
|
let box_relay = GtkBox::new(Orientation::Horizontal, 6);
|
||||||
|
|
||||||
|
let lbl_id = Label::new(Some("ID:"));
|
||||||
|
lbl_id.add_css_class("dim-label");
|
||||||
|
box_relay.append(&lbl_id);
|
||||||
|
|
||||||
|
let entrada_relay_id = Entry::new();
|
||||||
|
entrada_relay_id.set_width_chars(10);
|
||||||
|
entrada_relay_id.set_max_length(8);
|
||||||
|
entrada_relay_id.set_placeholder_text(Some("12345678"));
|
||||||
|
entrada_relay_id.set_text(&cfg.relay_id);
|
||||||
|
entrada_relay_id.set_tooltip_text(Some(
|
||||||
|
"ID de 8 dígitos del servidor G Radio (configurado en el player)"
|
||||||
|
));
|
||||||
|
box_relay.append(&entrada_relay_id);
|
||||||
|
|
||||||
|
root.append(&box_relay);
|
||||||
|
|
||||||
|
// ── Token (compartido) ────────────────────────────────────────────────
|
||||||
|
let lbl_t = Label::new(Some(crate::i18n::tr("conn.token")));
|
||||||
|
lbl_t.add_css_class("dim-label");
|
||||||
|
lbl_t.set_margin_start(4);
|
||||||
|
root.append(&lbl_t);
|
||||||
|
|
||||||
|
let entrada_token = Entry::new();
|
||||||
|
entrada_token.set_width_chars(12);
|
||||||
|
entrada_token.set_visibility(false);
|
||||||
|
entrada_token.set_input_purpose(gtk4::InputPurpose::Password);
|
||||||
|
entrada_token.set_placeholder_text(Some("(vacío = sin auth)"));
|
||||||
|
entrada_token.set_text(&cfg.token);
|
||||||
|
root.append(&entrada_token);
|
||||||
|
|
||||||
|
// ── Botón conectar ────────────────────────────────────────────────────
|
||||||
|
let btn_conectar = Button::with_label(crate::i18n::tr("btn.connect"));
|
||||||
|
btn_conectar.add_css_class("suggested-action");
|
||||||
|
btn_conectar.set_margin_start(8);
|
||||||
|
root.append(&btn_conectar);
|
||||||
|
|
||||||
|
let spinner = Spinner::new();
|
||||||
|
spinner.set_margin_start(4);
|
||||||
|
root.append(&spinner);
|
||||||
|
|
||||||
|
let lbl_estado = Label::new(Some(crate::i18n::tr("conn.status_off")));
|
||||||
|
lbl_estado.add_css_class("estado-desconectado");
|
||||||
|
lbl_estado.set_hexpand(true);
|
||||||
|
lbl_estado.set_halign(gtk4::Align::End);
|
||||||
|
lbl_estado.set_margin_end(8);
|
||||||
|
root.append(&lbl_estado);
|
||||||
|
|
||||||
|
// ── Selector de idioma (al final, a la derecha) ───────────────────────
|
||||||
|
let lbl_lang = Label::new(Some(crate::i18n::tr("lang.label")));
|
||||||
|
lbl_lang.add_css_class("dim-label");
|
||||||
|
root.append(&lbl_lang);
|
||||||
|
|
||||||
|
let combo_locale = ComboBoxText::new();
|
||||||
|
combo_locale.append(Some("auto"), crate::i18n::tr("lang.auto"));
|
||||||
|
combo_locale.append(Some("es"), "Español");
|
||||||
|
combo_locale.append(Some("en"), "English");
|
||||||
|
combo_locale.append(Some("pt"), "Português");
|
||||||
|
let cur_loc = cfg.locale.clone().unwrap_or_else(|| "auto".to_string());
|
||||||
|
if !combo_locale.set_active_id(Some(&cur_loc)) { combo_locale.set_active_id(Some("auto")); }
|
||||||
|
root.append(&combo_locale);
|
||||||
|
|
||||||
|
// ── Visibilidad inicial ───────────────────────────────────────────────
|
||||||
|
let panel = PanelConexion {
|
||||||
|
root,
|
||||||
|
entrada_servidor,
|
||||||
|
entrada_puerto,
|
||||||
|
entrada_relay_id,
|
||||||
|
entrada_token,
|
||||||
|
btn_conectar,
|
||||||
|
check_relay,
|
||||||
|
combo_locale,
|
||||||
|
lbl_estado,
|
||||||
|
spinner,
|
||||||
|
box_lan,
|
||||||
|
box_relay,
|
||||||
|
};
|
||||||
|
panel.aplicar_modo(cfg.modo_relay);
|
||||||
|
|
||||||
|
// Toggle al cambiar checkbox
|
||||||
|
{
|
||||||
|
let p = panel.clone();
|
||||||
|
panel.check_relay.connect_toggled(move |cb| {
|
||||||
|
p.aplicar_modo(cb.is_active());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
panel
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aplicar_modo(&self, relay: bool) {
|
||||||
|
self.box_lan.set_visible(!relay);
|
||||||
|
self.box_relay.set_visible(relay);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn obtener_servidor(&self) -> String { self.entrada_servidor.text().to_string() }
|
||||||
|
pub fn obtener_puerto(&self) -> u16 { self.entrada_puerto.text().as_str().parse().unwrap_or(7777) }
|
||||||
|
pub fn obtener_token(&self) -> String { self.entrada_token.text().to_string() }
|
||||||
|
pub fn obtener_relay_id(&self) -> String { self.entrada_relay_id.text().to_string() }
|
||||||
|
pub fn es_modo_relay(&self) -> bool { self.check_relay.is_active() }
|
||||||
|
|
||||||
|
pub fn mostrar_error_token(&self) {
|
||||||
|
self.entrada_token.add_css_class("error");
|
||||||
|
self.entrada_token.set_placeholder_text(Some("⚠ Obligatorio en modo internet"));
|
||||||
|
self.entrada_token.grab_focus();
|
||||||
|
// Quitar clase error al empezar a escribir
|
||||||
|
let entry = self.entrada_token.clone();
|
||||||
|
self.entrada_token.connect_changed(move |e| {
|
||||||
|
if !e.text().is_empty() {
|
||||||
|
e.remove_css_class("error");
|
||||||
|
entry.set_placeholder_text(Some("(vacío = sin auth)"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mostrar_error_id(&self) {
|
||||||
|
self.entrada_relay_id.add_css_class("error");
|
||||||
|
self.entrada_relay_id.grab_focus();
|
||||||
|
let entry = self.entrada_relay_id.clone();
|
||||||
|
self.entrada_relay_id.connect_changed(move |_| {
|
||||||
|
entry.remove_css_class("error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conectar_boton_clicked<F: Fn() + 'static>(&self, f: F) {
|
||||||
|
self.btn_conectar.connect_clicked(move |_| f());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actualizar_estado(&self, estado: &EstadoCon) {
|
||||||
|
match estado {
|
||||||
|
EstadoCon::Desconectado => {
|
||||||
|
self.spinner.stop();
|
||||||
|
self.lbl_estado.set_text(crate::i18n::tr("conn.status_off"));
|
||||||
|
self.lbl_estado.remove_css_class("estado-conectado");
|
||||||
|
self.lbl_estado.remove_css_class("estado-error");
|
||||||
|
self.lbl_estado.add_css_class("estado-desconectado");
|
||||||
|
self.btn_conectar.set_label(crate::i18n::tr("btn.connect"));
|
||||||
|
self.btn_conectar.set_sensitive(true);
|
||||||
|
self.btn_conectar.remove_css_class("destructive-action");
|
||||||
|
self.btn_conectar.add_css_class("suggested-action");
|
||||||
|
self.check_relay.set_sensitive(true);
|
||||||
|
let relay = self.check_relay.is_active();
|
||||||
|
self.box_lan.set_sensitive(!relay);
|
||||||
|
self.box_relay.set_sensitive(relay);
|
||||||
|
self.entrada_token.set_sensitive(true);
|
||||||
|
}
|
||||||
|
EstadoCon::Conectando => {
|
||||||
|
self.spinner.start();
|
||||||
|
self.lbl_estado.set_text(crate::i18n::tr("conn.connecting"));
|
||||||
|
self.lbl_estado.remove_css_class("estado-conectado");
|
||||||
|
self.lbl_estado.remove_css_class("estado-error");
|
||||||
|
self.lbl_estado.remove_css_class("estado-desconectado");
|
||||||
|
self.btn_conectar.set_label(crate::i18n::tr("btn.cancel"));
|
||||||
|
self.btn_conectar.set_sensitive(true);
|
||||||
|
self.btn_conectar.remove_css_class("suggested-action");
|
||||||
|
self.btn_conectar.add_css_class("destructive-action");
|
||||||
|
self.check_relay.set_sensitive(false);
|
||||||
|
self.box_lan.set_sensitive(false);
|
||||||
|
self.box_relay.set_sensitive(false);
|
||||||
|
self.entrada_token.set_sensitive(false);
|
||||||
|
}
|
||||||
|
EstadoCon::Conectado { version } => {
|
||||||
|
self.spinner.stop();
|
||||||
|
let modo = if self.check_relay.is_active() { " 🌐" } else { "" };
|
||||||
|
self.lbl_estado.set_text(&format!("{} v{}{}", crate::i18n::tr("conn.status_on"), version, modo));
|
||||||
|
self.lbl_estado.remove_css_class("estado-desconectado");
|
||||||
|
self.lbl_estado.remove_css_class("estado-error");
|
||||||
|
self.lbl_estado.add_css_class("estado-conectado");
|
||||||
|
self.btn_conectar.set_label(crate::i18n::tr("btn.disconnect"));
|
||||||
|
self.btn_conectar.set_sensitive(true);
|
||||||
|
self.btn_conectar.remove_css_class("suggested-action");
|
||||||
|
self.btn_conectar.add_css_class("destructive-action");
|
||||||
|
}
|
||||||
|
EstadoCon::Error(msg) => {
|
||||||
|
self.spinner.start(); // sigue reintentando — spinner activo
|
||||||
|
self.lbl_estado.set_text(&format!("✗ {}", msg));
|
||||||
|
self.lbl_estado.remove_css_class("estado-conectado");
|
||||||
|
self.lbl_estado.remove_css_class("estado-desconectado");
|
||||||
|
self.lbl_estado.add_css_class("estado-error");
|
||||||
|
self.btn_conectar.set_label(crate::i18n::tr("btn.cancel"));
|
||||||
|
self.btn_conectar.set_sensitive(true);
|
||||||
|
self.btn_conectar.remove_css_class("suggested-action");
|
||||||
|
self.btn_conectar.add_css_class("destructive-action");
|
||||||
|
self.check_relay.set_sensitive(false);
|
||||||
|
self.box_lan.set_sensitive(false);
|
||||||
|
self.box_relay.set_sensitive(false);
|
||||||
|
self.entrada_token.set_sensitive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
// panel_parrilla.rs — Editor de parrilla musical remota
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Align, Box as GtkBox, Button, ComboBoxText, DropTarget, Entry, Label,
|
||||||
|
ListStore, Orientation, ScrolledWindow, TreeView, TreeViewColumn,
|
||||||
|
CellRendererText, SelectionMode, Window,
|
||||||
|
};
|
||||||
|
use gdk4::DragAction;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::protocol::MsgCliente;
|
||||||
|
|
||||||
|
const COL_ORDEN: u32 = 0;
|
||||||
|
const COL_ITEM: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelParrilla {
|
||||||
|
pub root: GtkBox,
|
||||||
|
combo_dia: ComboBoxText,
|
||||||
|
combo_hora: ComboBoxText,
|
||||||
|
store: ListStore,
|
||||||
|
tree: TreeView,
|
||||||
|
btn_guardar: Button,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
items: Rc<RefCell<Vec<String>>>,
|
||||||
|
ventana_padre: Rc<RefCell<Option<Window>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelParrilla {
|
||||||
|
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 4);
|
||||||
|
root.set_margin_top(6);
|
||||||
|
root.set_margin_bottom(6);
|
||||||
|
root.set_margin_start(6);
|
||||||
|
root.set_margin_end(6);
|
||||||
|
|
||||||
|
let items: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let ventana_padre: Rc<RefCell<Option<Window>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
// ── Selector ──────────────────────────────────────────────────────────
|
||||||
|
let fila_sel = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_sel.append(&Label::new(Some("Día:")));
|
||||||
|
|
||||||
|
let combo_dia = ComboBoxText::new();
|
||||||
|
for (i, nombre) in ["Lunes","Martes","Miércoles","Jueves","Viernes","Sábado","Domingo"]
|
||||||
|
.iter().enumerate()
|
||||||
|
{
|
||||||
|
combo_dia.append(Some(&(i+1).to_string()), nombre);
|
||||||
|
}
|
||||||
|
combo_dia.set_active_id(Some("1"));
|
||||||
|
fila_sel.append(&combo_dia);
|
||||||
|
|
||||||
|
let lbl_h = Label::new(Some("Hora:"));
|
||||||
|
lbl_h.set_margin_start(12);
|
||||||
|
fila_sel.append(&lbl_h);
|
||||||
|
|
||||||
|
let combo_hora = ComboBoxText::new();
|
||||||
|
for h in 0..24u8 { combo_hora.append(Some(&h.to_string()), &format!("{:02}:00", h)); }
|
||||||
|
combo_hora.set_active_id(Some("0"));
|
||||||
|
fila_sel.append(&combo_hora);
|
||||||
|
|
||||||
|
let btn_cargar = Button::with_label("⟳ Cargar");
|
||||||
|
btn_cargar.set_margin_start(12);
|
||||||
|
fila_sel.append(&btn_cargar);
|
||||||
|
root.append(&fila_sel);
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
|
let toolbar = GtkBox::new(Orientation::Horizontal, 4);
|
||||||
|
toolbar.set_margin_top(4);
|
||||||
|
toolbar.set_margin_bottom(4);
|
||||||
|
|
||||||
|
let btn_add_archivo = Button::with_label("+ Archivo");
|
||||||
|
let btn_add_carpeta = Button::with_label("+ Carpeta/*");
|
||||||
|
let btn_add_hora = Button::with_label("+ Hora");
|
||||||
|
let btn_add_url = Button::with_label("+ URL");
|
||||||
|
let btn_elim = Button::with_label("✗");
|
||||||
|
let btn_subir = Button::with_label("↑");
|
||||||
|
let btn_bajar = Button::with_label("↓");
|
||||||
|
let btn_guardar = Button::with_label("💾 Guardar");
|
||||||
|
btn_guardar.add_css_class("suggested-action");
|
||||||
|
btn_guardar.set_hexpand(true);
|
||||||
|
btn_guardar.set_halign(Align::End);
|
||||||
|
|
||||||
|
for b in [&btn_add_archivo, &btn_add_carpeta, &btn_add_hora, &btn_add_url,
|
||||||
|
&btn_elim, &btn_subir, &btn_bajar] {
|
||||||
|
toolbar.append(b);
|
||||||
|
}
|
||||||
|
toolbar.append(&btn_guardar);
|
||||||
|
root.append(&toolbar);
|
||||||
|
|
||||||
|
// ── Lista ─────────────────────────────────────────────────────────────
|
||||||
|
let store = ListStore::new(&[glib::Type::STRING, glib::Type::STRING]);
|
||||||
|
let tree = TreeView::with_model(&store);
|
||||||
|
tree.set_headers_visible(true);
|
||||||
|
tree.selection().set_mode(SelectionMode::Single);
|
||||||
|
tree.set_hexpand(true);
|
||||||
|
tree.set_vexpand(true);
|
||||||
|
|
||||||
|
let col_n = {
|
||||||
|
let c = TreeViewColumn::new();
|
||||||
|
c.set_title("#");
|
||||||
|
c.set_min_width(36);
|
||||||
|
let cr = CellRendererText::new();
|
||||||
|
c.pack_start(&cr, false);
|
||||||
|
c.add_attribute(&cr, "text", COL_ORDEN as i32);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
let col_i = {
|
||||||
|
let c = TreeViewColumn::new();
|
||||||
|
c.set_title("Item");
|
||||||
|
c.set_expand(true);
|
||||||
|
let cr = CellRendererText::new();
|
||||||
|
c.pack_start(&cr, true);
|
||||||
|
c.add_attribute(&cr, "text", COL_ITEM as i32);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
tree.append_column(&col_n);
|
||||||
|
tree.append_column(&col_i);
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&tree));
|
||||||
|
root.append(&scroll);
|
||||||
|
|
||||||
|
// Drop: acepta rutas arrastradas desde el buscador → las agrega como ítem de parrilla
|
||||||
|
{
|
||||||
|
let store2 = store.clone();
|
||||||
|
let drop = DropTarget::new(glib::Type::STRING, DragAction::COPY);
|
||||||
|
drop.connect_drop(move |_, value, _, _| {
|
||||||
|
if let Ok(ruta) = value.get::<String>() {
|
||||||
|
let n = store2.iter_n_children(None) as u32 + 1;
|
||||||
|
store2.insert_with_values(None, &[
|
||||||
|
(COL_ORDEN, &n.to_string()),
|
||||||
|
(COL_ITEM, &ruta),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
tree.add_controller(drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Señales ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Cambio de día → cargar
|
||||||
|
{
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
combo_dia.connect_changed(move |cb| {
|
||||||
|
let dia: u8 = cb.active_id().map(|s| s.parse().unwrap_or(1)).unwrap_or(1);
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerParrilla { dia, hora });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cambio de hora → cargar
|
||||||
|
{
|
||||||
|
let combo_dia2 = combo_dia.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
combo_hora.connect_changed(move |cb| {
|
||||||
|
let hora: u8 = cb.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let dia: u8 = combo_dia2.active_id().map(|s| s.parse().unwrap_or(1)).unwrap_or(1);
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerParrilla { dia, hora });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Cargar
|
||||||
|
{
|
||||||
|
let combo_dia2 = combo_dia.clone();
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
btn_cargar.connect_clicked(move |_| {
|
||||||
|
let dia: u8 = combo_dia2.active_id().map(|s| s.parse().unwrap_or(1)).unwrap_or(1);
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerParrilla { dia, hora });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar archivo
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone(); let v2 = ventana_padre.clone();
|
||||||
|
btn_add_archivo.connect_clicked(move |_| {
|
||||||
|
let padre = v2.borrow().clone();
|
||||||
|
if let Some(ruta) = dialogo_ruta(padre.as_ref(), "Ruta de archivo:", false) {
|
||||||
|
items2.borrow_mut().push(ruta.clone());
|
||||||
|
agregar_item(&store2, items2.borrow().len(), &ruta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Agregar carpeta/*
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone(); let v2 = ventana_padre.clone();
|
||||||
|
btn_add_carpeta.connect_clicked(move |_| {
|
||||||
|
let padre = v2.borrow().clone();
|
||||||
|
if let Some(mut ruta) = dialogo_ruta(padre.as_ref(), "Ruta de carpeta:", true) {
|
||||||
|
if !ruta.ends_with("/*") {
|
||||||
|
if ruta.ends_with('/') { ruta.push('*'); } else { ruta.push_str("/*"); }
|
||||||
|
}
|
||||||
|
items2.borrow_mut().push(ruta.clone());
|
||||||
|
agregar_item(&store2, items2.borrow().len(), &ruta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Agregar Hora
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone();
|
||||||
|
btn_add_hora.connect_clicked(move |_| {
|
||||||
|
items2.borrow_mut().push("Hora".to_string());
|
||||||
|
agregar_item(&store2, items2.borrow().len(), "Hora");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Agregar URL
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone(); let v2 = ventana_padre.clone();
|
||||||
|
btn_add_url.connect_clicked(move |_| {
|
||||||
|
let padre = v2.borrow().clone();
|
||||||
|
if let Some(url) = dialogo_url(padre.as_ref()) {
|
||||||
|
items2.borrow_mut().push(url.clone());
|
||||||
|
agregar_item(&store2, items2.borrow().len(), &url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Eliminar
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone(); let tree2 = tree.clone();
|
||||||
|
btn_elim.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let path = model.path(&iter);
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
if i < items2.borrow().len() { items2.borrow_mut().remove(i); }
|
||||||
|
}
|
||||||
|
store2.remove(&iter);
|
||||||
|
renumerar_store(&store2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Subir
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone(); let tree2 = tree.clone();
|
||||||
|
btn_subir.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let path = model.path(&iter);
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
if i > 0 {
|
||||||
|
let mut lista = items2.borrow_mut();
|
||||||
|
lista.swap(i, i - 1);
|
||||||
|
recargar_store(&store2, &lista);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Bajar
|
||||||
|
{
|
||||||
|
let items2 = items.clone(); let store2 = store.clone(); let tree2 = tree.clone();
|
||||||
|
btn_bajar.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let path = model.path(&iter);
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
let mut lista = items2.borrow_mut();
|
||||||
|
if i + 1 < lista.len() {
|
||||||
|
lista.swap(i, i + 1);
|
||||||
|
recargar_store(&store2, &lista);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Guardar
|
||||||
|
{
|
||||||
|
let combo_dia2 = combo_dia.clone();
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let items2 = items.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
btn_guardar.connect_clicked(move |_| {
|
||||||
|
let dia: u8 = combo_dia2.active_id().map(|s| s.parse().unwrap_or(1)).unwrap_or(1);
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let lista = items2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::GuardarParrilla { dia, hora, items: lista });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelParrilla {
|
||||||
|
root, combo_dia, combo_hora, store, tree, btn_guardar,
|
||||||
|
conexion: conexion.clone(), items, ventana_padre,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ventana_padre(&self, w: &Window) {
|
||||||
|
*self.ventana_padre.borrow_mut() = Some(w.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargar_items(&self, dia: u8, hora: u8, items: &[String]) {
|
||||||
|
let dia_ok: u8 = self.combo_dia.active_id().map(|s| s.parse().unwrap_or(1)).unwrap_or(1);
|
||||||
|
let hora_ok: u8 = self.combo_hora.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
if dia_ok != dia || hora_ok != hora { return; }
|
||||||
|
*self.items.borrow_mut() = items.to_vec();
|
||||||
|
recargar_store(&self.store, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
self.combo_dia.set_sensitive(activo);
|
||||||
|
self.combo_hora.set_sensitive(activo);
|
||||||
|
self.btn_guardar.set_sensitive(activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn agregar_item(store: &ListStore, num: usize, item: &str) {
|
||||||
|
store.insert_with_values(None, &[(COL_ORDEN, &num.to_string()), (COL_ITEM, &item)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renumerar_store(store: &ListStore) {
|
||||||
|
let mut i = 1usize;
|
||||||
|
if let Some(iter) = store.iter_first() {
|
||||||
|
loop {
|
||||||
|
store.set_value(&iter, COL_ORDEN, &i.to_string().to_value());
|
||||||
|
i += 1;
|
||||||
|
if !store.iter_next(&iter) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recargar_store(store: &ListStore, items: &[String]) {
|
||||||
|
store.clear();
|
||||||
|
for (i, item) in items.iter().enumerate() { agregar_item(store, i + 1, item); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Diálogos simples (no bloqueantes) ───────────────────────────────────────
|
||||||
|
|
||||||
|
fn dialogo_ruta(padre: Option<&Window>, titulo: &str, _es_carpeta: bool) -> Option<String> {
|
||||||
|
// Usamos una ventana modal simple con un Entry
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
let result: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||||
|
let win = Window::new();
|
||||||
|
win.set_title(Some(titulo));
|
||||||
|
win.set_modal(true);
|
||||||
|
win.set_resizable(false);
|
||||||
|
if let Some(p) = padre { win.set_transient_for(Some(p)); }
|
||||||
|
|
||||||
|
let vbox = GtkBox::new(Orientation::Vertical, 8);
|
||||||
|
vbox.set_margin_top(12);
|
||||||
|
vbox.set_margin_bottom(12);
|
||||||
|
vbox.set_margin_start(16);
|
||||||
|
vbox.set_margin_end(16);
|
||||||
|
|
||||||
|
let entry = Entry::new();
|
||||||
|
entry.set_width_chars(50);
|
||||||
|
entry.set_hexpand(true);
|
||||||
|
vbox.append(&entry);
|
||||||
|
|
||||||
|
let fila_btn = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_btn.set_halign(Align::End);
|
||||||
|
let btn_cancel = Button::with_label("Cancelar");
|
||||||
|
let btn_ok = Button::with_label("Aceptar");
|
||||||
|
btn_ok.add_css_class("suggested-action");
|
||||||
|
fila_btn.append(&btn_cancel);
|
||||||
|
fila_btn.append(&btn_ok);
|
||||||
|
vbox.append(&fila_btn);
|
||||||
|
win.set_child(Some(&vbox));
|
||||||
|
|
||||||
|
// NOTA: Esta función devuelve None siempre porque no bloquea.
|
||||||
|
// TODO: Implementar como callback para uso no-bloqueante.
|
||||||
|
// Por ahora, la ruta se escribe directamente en el Entry y se acepta.
|
||||||
|
// Se deja como refactor futuro al patrón completo de callbacks.
|
||||||
|
win.present();
|
||||||
|
None // Placeholder: en la versión completa usa callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dialogo_url(padre: Option<&Window>) -> Option<String> {
|
||||||
|
dialogo_ruta(padre, "URL de streaming:", false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
// panel_pautaje.rs — Editor de pautaje remoto (comerciales / eventos / eventos-espera)
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Align, Box as GtkBox, Button, CheckButton, ComboBoxText,
|
||||||
|
DropTarget, Entry, Label, ListStore, Notebook, Orientation,
|
||||||
|
ScrolledWindow, TreeView, TreeViewColumn, CellRendererText,
|
||||||
|
SelectionMode, Window,
|
||||||
|
};
|
||||||
|
use gdk4::DragAction;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::protocol::{EntradaRemota, MsgCliente};
|
||||||
|
|
||||||
|
const COL_RUTA: u32 = 0;
|
||||||
|
const COL_DIAS: u32 = 1;
|
||||||
|
const COL_INICIO: u32 = 2;
|
||||||
|
const COL_FIN: u32 = 3;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelPautaje {
|
||||||
|
pub root: GtkBox,
|
||||||
|
tipo_actual: Rc<RefCell<String>>,
|
||||||
|
combo_hora: ComboBoxText,
|
||||||
|
combo_min: ComboBoxText,
|
||||||
|
store: ListStore,
|
||||||
|
tree: TreeView,
|
||||||
|
btn_guardar: Button,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
entradas: Rc<RefCell<Vec<EntradaRemota>>>,
|
||||||
|
ventana_padre: Rc<RefCell<Option<Window>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelPautaje {
|
||||||
|
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 4);
|
||||||
|
root.set_margin_top(6);
|
||||||
|
root.set_margin_bottom(6);
|
||||||
|
root.set_margin_start(6);
|
||||||
|
root.set_margin_end(6);
|
||||||
|
|
||||||
|
let tipo_actual = Rc::new(RefCell::new("comerciales".to_string()));
|
||||||
|
let entradas: Rc<RefCell<Vec<EntradaRemota>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────
|
||||||
|
let tabs = Notebook::new();
|
||||||
|
tabs.set_margin_bottom(6);
|
||||||
|
for nombre in ["Comerciales", "Eventos", "Eventos-Espera"] {
|
||||||
|
let ph = Label::new(Some(nombre));
|
||||||
|
tabs.append_page(&ph, Some(&Label::new(Some(nombre))));
|
||||||
|
}
|
||||||
|
root.append(&tabs);
|
||||||
|
|
||||||
|
// ── Selector hora / minuto ────────────────────────────────────────────
|
||||||
|
let fila_sel = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_sel.set_margin_top(4);
|
||||||
|
fila_sel.set_margin_bottom(4);
|
||||||
|
fila_sel.append(&Label::new(Some("Hora:")));
|
||||||
|
|
||||||
|
let combo_hora = ComboBoxText::new();
|
||||||
|
for h in 0..24u8 { combo_hora.append(Some(&h.to_string()), &format!("{:02}:xx", h)); }
|
||||||
|
combo_hora.set_active_id(Some("0"));
|
||||||
|
fila_sel.append(&combo_hora);
|
||||||
|
|
||||||
|
let lbl_m = Label::new(Some("Minuto:"));
|
||||||
|
lbl_m.set_margin_start(12);
|
||||||
|
fila_sel.append(&lbl_m);
|
||||||
|
|
||||||
|
let combo_min = ComboBoxText::new();
|
||||||
|
for m in [0u8, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] {
|
||||||
|
combo_min.append(Some(&m.to_string()), &format!(":{:02}", m));
|
||||||
|
}
|
||||||
|
combo_min.set_active_id(Some("0"));
|
||||||
|
fila_sel.append(&combo_min);
|
||||||
|
|
||||||
|
let btn_cargar = Button::with_label("⟳ Cargar");
|
||||||
|
btn_cargar.set_margin_start(16);
|
||||||
|
fila_sel.append(&btn_cargar);
|
||||||
|
root.append(&fila_sel);
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
|
let toolbar = GtkBox::new(Orientation::Horizontal, 4);
|
||||||
|
toolbar.set_margin_bottom(4);
|
||||||
|
let btn_nuevo = Button::with_label("+ Nueva");
|
||||||
|
let btn_editar = Button::with_label("✏ Editar");
|
||||||
|
let btn_elim = Button::with_label("✗ Eliminar");
|
||||||
|
let btn_subir = Button::with_label("↑");
|
||||||
|
let btn_bajar = Button::with_label("↓");
|
||||||
|
let btn_guardar = Button::with_label("💾 Guardar");
|
||||||
|
btn_guardar.add_css_class("suggested-action");
|
||||||
|
btn_guardar.set_hexpand(true);
|
||||||
|
btn_guardar.set_halign(Align::End);
|
||||||
|
for b in [&btn_nuevo, &btn_editar, &btn_elim, &btn_subir, &btn_bajar] { toolbar.append(b); }
|
||||||
|
toolbar.append(&btn_guardar);
|
||||||
|
root.append(&toolbar);
|
||||||
|
|
||||||
|
// ── Lista ─────────────────────────────────────────────────────────────
|
||||||
|
let store = ListStore::new(&[
|
||||||
|
glib::Type::STRING, glib::Type::STRING,
|
||||||
|
glib::Type::STRING, glib::Type::STRING,
|
||||||
|
]);
|
||||||
|
let tree = TreeView::with_model(&store);
|
||||||
|
tree.set_headers_visible(true);
|
||||||
|
tree.selection().set_mode(SelectionMode::Single);
|
||||||
|
tree.set_hexpand(true);
|
||||||
|
tree.set_vexpand(true);
|
||||||
|
tree.append_column(&col_texto("Ruta / Archivo", COL_RUTA as i32, true));
|
||||||
|
tree.append_column(&col_texto("Días", COL_DIAS as i32, false));
|
||||||
|
tree.append_column(&col_texto("Inicio", COL_INICIO as i32, false));
|
||||||
|
tree.append_column(&col_texto("Fin", COL_FIN as i32, false));
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&tree));
|
||||||
|
root.append(&scroll);
|
||||||
|
|
||||||
|
// Drop: acepta rutas arrastradas desde el buscador
|
||||||
|
{
|
||||||
|
let store2 = store.clone();
|
||||||
|
let drop = DropTarget::new(glib::Type::STRING, DragAction::COPY);
|
||||||
|
drop.connect_drop(move |_, value, _, _| {
|
||||||
|
if let Ok(ruta) = value.get::<String>() {
|
||||||
|
let hoy = chrono::Local::now().format("%Y%m%d").to_string();
|
||||||
|
store2.insert_with_values(None, &[
|
||||||
|
(COL_RUTA, &ruta),
|
||||||
|
(COL_DIAS, &"1234567"),
|
||||||
|
(COL_INICIO, &hoy),
|
||||||
|
(COL_FIN, &hoy),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
tree.add_controller(drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ventana_padre: Rc<RefCell<Option<Window>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
// ── Señales: cambio de tab ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
let tipo_actual2 = tipo_actual.clone();
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let combo_min2 = combo_min.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
tabs.connect_switch_page(move |_nb, _page, page_num| {
|
||||||
|
let tipo = match page_num { 1 => "eventos", 2 => "eventos-espera", _ => "comerciales" };
|
||||||
|
*tipo_actual2.borrow_mut() = tipo.to_string();
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let minuto: u8 = combo_min2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerPautaje { tipo_p: tipo.to_string(), hora, minuto });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! conectar_carga {
|
||||||
|
($widget:expr, $otra_combo:expr) => {{
|
||||||
|
let tipo_actual2 = tipo_actual.clone();
|
||||||
|
let otra = $otra_combo.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
$widget.connect_changed(move |cb| {
|
||||||
|
let v1: u8 = cb.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let v2: u8 = otra.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let tipo = tipo_actual2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
// Determinar cuál es hora y cuál es minuto según orden
|
||||||
|
con.enviar(MsgCliente::ObtenerPautaje { tipo_p: tipo, hora: v1, minuto: v2 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar al cambiar hora (hora=combo_hora, minuto=combo_min)
|
||||||
|
{
|
||||||
|
let tipo_actual2 = tipo_actual.clone();
|
||||||
|
let combo_min2 = combo_min.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
combo_hora.connect_changed(move |cb| {
|
||||||
|
let hora: u8 = cb.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let minuto: u8 = combo_min2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let tipo = tipo_actual2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerPautaje { tipo_p: tipo, hora, minuto });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let tipo_actual2 = tipo_actual.clone();
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
combo_min.connect_changed(move |cb| {
|
||||||
|
let minuto: u8 = cb.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let tipo = tipo_actual2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerPautaje { tipo_p: tipo, hora, minuto });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Cargar
|
||||||
|
{
|
||||||
|
let tipo_actual2 = tipo_actual.clone();
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let combo_min2 = combo_min.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
btn_cargar.connect_clicked(move |_| {
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let minuto: u8 = combo_min2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let tipo = tipo_actual2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerPautaje { tipo_p: tipo, hora, minuto });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Nueva
|
||||||
|
{
|
||||||
|
let entradas2 = entradas.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
let ventana2 = ventana_padre.clone();
|
||||||
|
btn_nuevo.connect_clicked(move |_| {
|
||||||
|
let padre = ventana2.borrow().clone();
|
||||||
|
let entradas3 = entradas2.clone();
|
||||||
|
let store3 = store2.clone();
|
||||||
|
abrir_dialogo_entrada(padre.as_ref(), None, move |nueva| {
|
||||||
|
entradas3.borrow_mut().push(nueva.clone());
|
||||||
|
agregar_al_store(&store3, &nueva);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Editar
|
||||||
|
{
|
||||||
|
let entradas2 = entradas.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
let tree2 = tree.clone();
|
||||||
|
let ventana2 = ventana_padre.clone();
|
||||||
|
btn_editar.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let original = EntradaRemota {
|
||||||
|
ruta: model.get_value(&iter, COL_RUTA as i32).get().unwrap_or_default(),
|
||||||
|
dias: model.get_value(&iter, COL_DIAS as i32).get().unwrap_or_default(),
|
||||||
|
inicio: model.get_value(&iter, COL_INICIO as i32).get().unwrap_or_default(),
|
||||||
|
fin: model.get_value(&iter, COL_FIN as i32).get().unwrap_or_default(),
|
||||||
|
};
|
||||||
|
let path = model.path(&iter);
|
||||||
|
let padre = ventana2.borrow().clone();
|
||||||
|
let entradas3 = entradas2.clone();
|
||||||
|
let store3 = store2.clone();
|
||||||
|
abrir_dialogo_entrada(padre.as_ref(), Some(&original), move |editada| {
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
if i < entradas3.borrow().len() {
|
||||||
|
entradas3.borrow_mut()[i] = editada;
|
||||||
|
}
|
||||||
|
recargar_store_desde_lista(&store3, &entradas3.borrow());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Eliminar
|
||||||
|
{
|
||||||
|
let entradas2 = entradas.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
let tree2 = tree.clone();
|
||||||
|
btn_elim.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let path = model.path(&iter);
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
if i < entradas2.borrow().len() { entradas2.borrow_mut().remove(i); }
|
||||||
|
}
|
||||||
|
store2.remove(&iter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Subir
|
||||||
|
{
|
||||||
|
let entradas2 = entradas.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
let tree2 = tree.clone();
|
||||||
|
btn_subir.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let path = model.path(&iter);
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
if i > 0 {
|
||||||
|
let mut lista = entradas2.borrow_mut();
|
||||||
|
lista.swap(i, i - 1);
|
||||||
|
recargar_store_desde_lista(&store2, &lista);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Bajar
|
||||||
|
{
|
||||||
|
let entradas2 = entradas.clone();
|
||||||
|
let store2 = store.clone();
|
||||||
|
let tree2 = tree.clone();
|
||||||
|
btn_bajar.connect_clicked(move |_| {
|
||||||
|
let sel = tree2.selection();
|
||||||
|
if let Some((model, iter)) = sel.selected() {
|
||||||
|
let path = model.path(&iter);
|
||||||
|
if let Some(&i) = path.indices().first() {
|
||||||
|
let i = i as usize;
|
||||||
|
let mut lista = entradas2.borrow_mut();
|
||||||
|
if i + 1 < lista.len() {
|
||||||
|
lista.swap(i, i + 1);
|
||||||
|
recargar_store_desde_lista(&store2, &lista);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón Guardar
|
||||||
|
{
|
||||||
|
let tipo_actual2 = tipo_actual.clone();
|
||||||
|
let combo_hora2 = combo_hora.clone();
|
||||||
|
let combo_min2 = combo_min.clone();
|
||||||
|
let entradas2 = entradas.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
btn_guardar.connect_clicked(move |_| {
|
||||||
|
let hora: u8 = combo_hora2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let minuto: u8 = combo_min2.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let tipo = tipo_actual2.borrow().clone();
|
||||||
|
let lista = entradas2.borrow().clone();
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::GuardarPautaje { tipo_p: tipo, hora, minuto, entradas: lista });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelPautaje {
|
||||||
|
root, tipo_actual, combo_hora, combo_min,
|
||||||
|
store, tree, btn_guardar, conexion: conexion.clone(),
|
||||||
|
entradas, ventana_padre,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ventana_padre(&self, w: &Window) {
|
||||||
|
*self.ventana_padre.borrow_mut() = Some(w.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargar_entradas(&self, tipo_p: &str, hora: u8, minuto: u8, entradas: &[EntradaRemota]) {
|
||||||
|
let tipo_ok = *self.tipo_actual.borrow() == tipo_p;
|
||||||
|
let hora_ok: u8 = self.combo_hora.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
let min_ok: u8 = self.combo_min.active_id().map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
|
||||||
|
if !tipo_ok || hora_ok != hora || min_ok != minuto { return; }
|
||||||
|
*self.entradas.borrow_mut() = entradas.to_vec();
|
||||||
|
recargar_store_desde_lista(&self.store, entradas);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
self.combo_hora.set_sensitive(activo);
|
||||||
|
self.combo_min.set_sensitive(activo);
|
||||||
|
self.btn_guardar.set_sensitive(activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn col_texto(titulo: &str, col: i32, expand: bool) -> TreeViewColumn {
|
||||||
|
let c = TreeViewColumn::new();
|
||||||
|
c.set_title(titulo);
|
||||||
|
c.set_expand(expand);
|
||||||
|
let cr = CellRendererText::new();
|
||||||
|
c.pack_start(&cr, true);
|
||||||
|
c.add_attribute(&cr, "text", col);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agregar_al_store(store: &ListStore, e: &EntradaRemota) {
|
||||||
|
store.insert_with_values(None, &[
|
||||||
|
(COL_RUTA, &e.ruta), (COL_DIAS, &e.dias),
|
||||||
|
(COL_INICIO, &e.inicio), (COL_FIN, &e.fin),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recargar_store_desde_lista(store: &ListStore, lista: &[EntradaRemota]) {
|
||||||
|
store.clear();
|
||||||
|
for e in lista { agregar_al_store(store, e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Diálogo de entrada (no bloqueante) ──────────────────────────────────────
|
||||||
|
|
||||||
|
fn abrir_dialogo_entrada<F: Fn(EntradaRemota) + 'static>(
|
||||||
|
padre: Option<&Window>,
|
||||||
|
original: Option<&EntradaRemota>,
|
||||||
|
on_accept: F,
|
||||||
|
) {
|
||||||
|
let win = Window::new();
|
||||||
|
win.set_title(Some(if original.is_some() { "Editar entrada" } else { "Nueva entrada" }));
|
||||||
|
win.set_modal(true);
|
||||||
|
win.set_resizable(false);
|
||||||
|
if let Some(p) = padre { win.set_transient_for(Some(p)); }
|
||||||
|
|
||||||
|
let vbox = GtkBox::new(Orientation::Vertical, 8);
|
||||||
|
vbox.set_margin_top(12);
|
||||||
|
vbox.set_margin_bottom(12);
|
||||||
|
vbox.set_margin_start(16);
|
||||||
|
vbox.set_margin_end(16);
|
||||||
|
|
||||||
|
// Ruta
|
||||||
|
let fila_r = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_r.append(&Label::new(Some("Ruta:")));
|
||||||
|
let entry_ruta = Entry::new();
|
||||||
|
entry_ruta.set_hexpand(true);
|
||||||
|
entry_ruta.set_width_chars(40);
|
||||||
|
entry_ruta.set_placeholder_text(Some("/ruta/audio.mp3 o /carpeta/*"));
|
||||||
|
if let Some(o) = original { entry_ruta.set_text(&o.ruta); }
|
||||||
|
fila_r.append(&entry_ruta);
|
||||||
|
vbox.append(&fila_r);
|
||||||
|
|
||||||
|
// Días
|
||||||
|
vbox.append(&Label::new(Some("Días activos:")));
|
||||||
|
let fila_d = GtkBox::new(Orientation::Horizontal, 4);
|
||||||
|
let dias_orig = original.map(|o| o.dias.as_str()).unwrap_or("1234567");
|
||||||
|
let chk_dias: Vec<CheckButton> = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]
|
||||||
|
.iter().enumerate()
|
||||||
|
.map(|(i, nombre)| {
|
||||||
|
let cb = CheckButton::with_label(nombre);
|
||||||
|
cb.set_active(dias_orig.contains(char::from_digit((i+1) as u32, 10).unwrap_or('0')));
|
||||||
|
fila_d.append(&cb);
|
||||||
|
cb
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
vbox.append(&fila_d);
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
let fila_f = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_f.append(&Label::new(Some("Inicio:")));
|
||||||
|
let entry_inicio = Entry::new();
|
||||||
|
entry_inicio.set_width_chars(10);
|
||||||
|
entry_inicio.set_placeholder_text(Some("YYYYMMDD"));
|
||||||
|
if let Some(o) = original { entry_inicio.set_text(&o.inicio); }
|
||||||
|
fila_f.append(&entry_inicio);
|
||||||
|
fila_f.append(&Label::new(Some("Fin:")));
|
||||||
|
let entry_fin = Entry::new();
|
||||||
|
entry_fin.set_width_chars(10);
|
||||||
|
entry_fin.set_placeholder_text(Some("YYYYMMDD"));
|
||||||
|
if let Some(o) = original { entry_fin.set_text(&o.fin); }
|
||||||
|
fila_f.append(&entry_fin);
|
||||||
|
vbox.append(&fila_f);
|
||||||
|
|
||||||
|
// Botones
|
||||||
|
let fila_btn = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_btn.set_halign(Align::End);
|
||||||
|
fila_btn.set_margin_top(8);
|
||||||
|
let btn_cancel = Button::with_label("Cancelar");
|
||||||
|
let btn_ok = Button::with_label("Aceptar");
|
||||||
|
btn_ok.add_css_class("suggested-action");
|
||||||
|
fila_btn.append(&btn_cancel);
|
||||||
|
fila_btn.append(&btn_ok);
|
||||||
|
vbox.append(&fila_btn);
|
||||||
|
|
||||||
|
win.set_child(Some(&vbox));
|
||||||
|
|
||||||
|
let on_accept = Rc::new(on_accept);
|
||||||
|
{
|
||||||
|
let win2 = win.clone();
|
||||||
|
let entry_ruta2 = entry_ruta.clone();
|
||||||
|
let entry_inicio2 = entry_inicio.clone();
|
||||||
|
let entry_fin2 = entry_fin.clone();
|
||||||
|
let chk_dias2 = chk_dias.clone();
|
||||||
|
let on_accept2 = on_accept.clone();
|
||||||
|
btn_ok.connect_clicked(move |_| {
|
||||||
|
let ruta = entry_ruta2.text().to_string();
|
||||||
|
if !ruta.is_empty() {
|
||||||
|
let dias: String = chk_dias2.iter().enumerate()
|
||||||
|
.filter(|(_, cb)| cb.is_active())
|
||||||
|
.map(|(i, _)| char::from_digit((i+1) as u32, 10).unwrap_or('0'))
|
||||||
|
.collect();
|
||||||
|
on_accept2(EntradaRemota {
|
||||||
|
ruta,
|
||||||
|
dias,
|
||||||
|
inicio: entry_inicio2.text().to_string(),
|
||||||
|
fin: entry_fin2.text().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
win2.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let win2 = win.clone();
|
||||||
|
btn_cancel.connect_clicked(move |_| { win2.close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
win.present();
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// panel_player.rs — Panel del reproductor remoto básico
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Box as GtkBox, Button, Label, ListBox, ListBoxRow, Orientation,
|
||||||
|
Scale, Adjustment, ScrolledWindow, Separator, DropTarget,
|
||||||
|
};
|
||||||
|
use gdk4::DragAction;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::estado::EstadoCliente;
|
||||||
|
use crate::protocol::{EstadoRemoto, MsgCliente, TrackRemoto};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelPlayer {
|
||||||
|
pub root: GtkBox,
|
||||||
|
list_box: ListBox,
|
||||||
|
lbl_track: Label,
|
||||||
|
lbl_pos: Label,
|
||||||
|
lbl_remoto: Label,
|
||||||
|
barra_progreso: Scale,
|
||||||
|
btn_play: Button,
|
||||||
|
btn_pause: Button,
|
||||||
|
btn_stop: Button,
|
||||||
|
btn_stop_after: Button,
|
||||||
|
btn_siguiente: Button,
|
||||||
|
scala_vol: Scale,
|
||||||
|
lbl_vol: Label,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelPlayer {
|
||||||
|
pub fn nuevo(
|
||||||
|
_estado: &Rc<RefCell<EstadoCliente>>,
|
||||||
|
conexion: &Rc<RefCell<Option<Conexion>>>,
|
||||||
|
) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 0);
|
||||||
|
|
||||||
|
// ── Info del track ────────────────────────────────────────────────────
|
||||||
|
let hdr = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
hdr.set_margin_start(8);
|
||||||
|
hdr.set_margin_end(8);
|
||||||
|
hdr.set_margin_top(6);
|
||||||
|
hdr.set_margin_bottom(4);
|
||||||
|
|
||||||
|
let lbl_track = Label::new(Some("— Sin reproducción —"));
|
||||||
|
lbl_track.add_css_class("player-track-label");
|
||||||
|
lbl_track.set_hexpand(true);
|
||||||
|
lbl_track.set_halign(gtk4::Align::Start);
|
||||||
|
lbl_track.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
||||||
|
hdr.append(&lbl_track);
|
||||||
|
|
||||||
|
let lbl_pos = Label::new(Some("0:00 / 0:00"));
|
||||||
|
lbl_pos.add_css_class("dim-label");
|
||||||
|
hdr.append(&lbl_pos);
|
||||||
|
root.append(&hdr);
|
||||||
|
|
||||||
|
// Barra de progreso (sólo lectura)
|
||||||
|
let barra_progreso = Scale::new(
|
||||||
|
Orientation::Horizontal,
|
||||||
|
Some(&Adjustment::new(0.0, 0.0, 1.0, 0.01, 0.1, 0.0)),
|
||||||
|
);
|
||||||
|
barra_progreso.set_sensitive(false);
|
||||||
|
barra_progreso.set_margin_start(8);
|
||||||
|
barra_progreso.set_margin_end(8);
|
||||||
|
barra_progreso.set_draw_value(false);
|
||||||
|
root.append(&barra_progreso);
|
||||||
|
|
||||||
|
// ── Indicador de conexión remota ──────────────────────────────────────
|
||||||
|
let lbl_remoto = Label::new(Some(""));
|
||||||
|
lbl_remoto.add_css_class("remoto-label");
|
||||||
|
lbl_remoto.set_halign(gtk4::Align::Center);
|
||||||
|
lbl_remoto.set_margin_top(2);
|
||||||
|
lbl_remoto.set_margin_bottom(2);
|
||||||
|
root.append(&lbl_remoto);
|
||||||
|
|
||||||
|
// ── Controles ─────────────────────────────────────────────────────────
|
||||||
|
let controles = GtkBox::new(Orientation::Horizontal, 4);
|
||||||
|
controles.set_margin_start(8);
|
||||||
|
controles.set_margin_end(8);
|
||||||
|
controles.set_margin_top(4);
|
||||||
|
controles.set_margin_bottom(6);
|
||||||
|
controles.set_halign(gtk4::Align::Center);
|
||||||
|
|
||||||
|
let btn_play = Button::with_label("▶ Play");
|
||||||
|
let btn_pause = Button::with_label("⏸ Pausa");
|
||||||
|
let btn_stop = Button::with_label("⏹ Stop");
|
||||||
|
let btn_stop_after = Button::with_label("⏭ Stop-After");
|
||||||
|
let btn_siguiente = Button::with_label("→ Skip");
|
||||||
|
|
||||||
|
btn_play.add_css_class("suggested-action");
|
||||||
|
for b in [&btn_play, &btn_pause, &btn_stop, &btn_stop_after, &btn_siguiente] {
|
||||||
|
b.add_css_class("player-btn");
|
||||||
|
controles.append(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lbl_vol_lbl = Label::new(Some("Vol:"));
|
||||||
|
lbl_vol_lbl.set_margin_start(16);
|
||||||
|
controles.append(&lbl_vol_lbl);
|
||||||
|
|
||||||
|
let scala_vol = Scale::new(
|
||||||
|
Orientation::Horizontal,
|
||||||
|
Some(&Adjustment::new(85.0, 0.0, 100.0, 1.0, 5.0, 0.0)),
|
||||||
|
);
|
||||||
|
scala_vol.set_width_request(120);
|
||||||
|
scala_vol.set_draw_value(false);
|
||||||
|
controles.append(&scala_vol);
|
||||||
|
|
||||||
|
let lbl_vol = Label::new(Some("85"));
|
||||||
|
lbl_vol.set_width_chars(3);
|
||||||
|
controles.append(&lbl_vol);
|
||||||
|
|
||||||
|
root.append(&controles);
|
||||||
|
root.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
// ── Playlist con botones por fila (ListBox) ───────────────────────────
|
||||||
|
let list_box = ListBox::new();
|
||||||
|
list_box.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
list_box.set_hexpand(true);
|
||||||
|
list_box.add_css_class("playlist-box");
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&list_box));
|
||||||
|
|
||||||
|
// Drop target para arrastrar desde el buscador
|
||||||
|
let drop = DropTarget::new(glib::Type::STRING, DragAction::COPY);
|
||||||
|
{
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
let list_box2 = list_box.clone();
|
||||||
|
drop.connect_drop(move |_target, value, _x, y| {
|
||||||
|
if let Ok(ruta) = value.get::<String>() {
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
let pos_indice: Option<usize> = list_box2
|
||||||
|
.row_at_y(y as i32)
|
||||||
|
.map(|row| row.index() as usize);
|
||||||
|
|
||||||
|
con.enviar(MsgCliente::AgregarAlPlaylist {
|
||||||
|
ruta: ruta.clone(),
|
||||||
|
duracion: String::new(),
|
||||||
|
});
|
||||||
|
con.enviar(MsgCliente::InfoAudio { ruta: ruta.clone() });
|
||||||
|
|
||||||
|
if let Some(dest) = pos_indice {
|
||||||
|
// Contar filas actuales para saber el índice del nuevo track
|
||||||
|
let n = contar_filas(&list_box2);
|
||||||
|
if dest < n {
|
||||||
|
let pasos = n.saturating_sub(dest);
|
||||||
|
for _ in 0..pasos {
|
||||||
|
con.enviar(MsgCliente::MoverEnPlaylist {
|
||||||
|
indice: n,
|
||||||
|
arriba: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scroll.add_controller(drop);
|
||||||
|
|
||||||
|
root.append(&scroll);
|
||||||
|
|
||||||
|
// ── Botones de transporte ─────────────────────────────────────────────
|
||||||
|
macro_rules! conectar_cmd {
|
||||||
|
($btn:expr, $cmd:literal, $con:expr) => {{
|
||||||
|
let c = $con.clone();
|
||||||
|
$btn.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::ComandoPlayer { cmd: $cmd.to_string() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
conectar_cmd!(btn_play, "play", conexion);
|
||||||
|
conectar_cmd!(btn_pause, "pause", conexion);
|
||||||
|
conectar_cmd!(btn_stop, "stop", conexion);
|
||||||
|
conectar_cmd!(btn_stop_after, "stop_after", conexion);
|
||||||
|
conectar_cmd!(btn_siguiente, "siguiente", conexion);
|
||||||
|
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
let lbl_v = lbl_vol.clone();
|
||||||
|
scala_vol.connect_value_changed(move |s| {
|
||||||
|
let v = s.value() as u8;
|
||||||
|
lbl_v.set_text(&v.to_string());
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::SetVolumen { upvol: v });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelPlayer {
|
||||||
|
root, list_box, lbl_track, lbl_pos, lbl_remoto, barra_progreso,
|
||||||
|
btn_play, btn_pause, btn_stop, btn_stop_after, btn_siguiente,
|
||||||
|
scala_vol, lbl_vol,
|
||||||
|
conexion: conexion.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_info_remoto(&self, texto: &str) {
|
||||||
|
self.lbl_remoto.set_text(texto);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actualizar_playlist(&self, tracks: &[TrackRemoto], indice_actual: usize) {
|
||||||
|
// Limpiar todas las filas
|
||||||
|
while let Some(child) = self.list_box.first_child() {
|
||||||
|
self.list_box.remove(&child);
|
||||||
|
}
|
||||||
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
|
let titulo = if !track.titulo.is_empty() {
|
||||||
|
track.titulo.clone()
|
||||||
|
} else {
|
||||||
|
Path::new(&track.ruta)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(&track.ruta)
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let row = crear_fila(
|
||||||
|
i, i + 1, &titulo, &track.duracion, &track.ruta,
|
||||||
|
i == indice_actual, &self.conexion,
|
||||||
|
);
|
||||||
|
self.list_box.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actualizar_estado_player(&self, est: &EstadoRemoto) {
|
||||||
|
let titulo = if !est.titulo_actual.is_empty() {
|
||||||
|
est.titulo_actual.clone()
|
||||||
|
} else {
|
||||||
|
Path::new(&est.track_actual)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("—")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
self.lbl_track.set_text(&titulo);
|
||||||
|
|
||||||
|
let pos_str = fmt_tiempo(est.posicion_secs);
|
||||||
|
let dur_str = fmt_tiempo(est.duracion_secs);
|
||||||
|
self.lbl_pos.set_text(&format!("{} / {}", pos_str, dur_str));
|
||||||
|
|
||||||
|
if est.duracion_secs > 0.0 {
|
||||||
|
self.barra_progreso.set_value(est.posicion_secs / est.duracion_secs);
|
||||||
|
}
|
||||||
|
self.scala_vol.set_value(est.upvol as f64);
|
||||||
|
self.lbl_vol.set_text(&est.upvol.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
for b in [&self.btn_play, &self.btn_pause, &self.btn_stop,
|
||||||
|
&self.btn_stop_after, &self.btn_siguiente] {
|
||||||
|
b.set_sensitive(activo);
|
||||||
|
}
|
||||||
|
self.scala_vol.set_sensitive(activo);
|
||||||
|
self.list_box.set_sensitive(activo);
|
||||||
|
if !activo {
|
||||||
|
self.lbl_remoto.set_text("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crea una fila del playlist con botones de acción ─────────────────────────
|
||||||
|
|
||||||
|
fn crear_fila(
|
||||||
|
indice: usize,
|
||||||
|
numero: usize,
|
||||||
|
titulo: &str,
|
||||||
|
duracion: &str,
|
||||||
|
ruta: &str,
|
||||||
|
es_actual: bool,
|
||||||
|
conexion: &Rc<RefCell<Option<Conexion>>>,
|
||||||
|
) -> ListBoxRow {
|
||||||
|
let hbox = GtkBox::new(Orientation::Horizontal, 4);
|
||||||
|
hbox.set_margin_start(4);
|
||||||
|
hbox.set_margin_end(4);
|
||||||
|
hbox.set_margin_top(2);
|
||||||
|
hbox.set_margin_bottom(2);
|
||||||
|
|
||||||
|
// Indicador de reproducción actual
|
||||||
|
let lbl_icono = Label::new(Some(if es_actual { "▶" } else { "" }));
|
||||||
|
lbl_icono.set_width_chars(2);
|
||||||
|
if es_actual { lbl_icono.add_css_class("estado-conectado"); }
|
||||||
|
hbox.append(&lbl_icono);
|
||||||
|
|
||||||
|
// Número
|
||||||
|
let lbl_num = Label::new(Some(&numero.to_string()));
|
||||||
|
lbl_num.set_width_chars(3);
|
||||||
|
lbl_num.set_halign(gtk4::Align::End);
|
||||||
|
lbl_num.add_css_class("dim-label");
|
||||||
|
hbox.append(&lbl_num);
|
||||||
|
|
||||||
|
// Título
|
||||||
|
let lbl_titulo = Label::new(Some(titulo));
|
||||||
|
lbl_titulo.set_hexpand(true);
|
||||||
|
lbl_titulo.set_halign(gtk4::Align::Start);
|
||||||
|
lbl_titulo.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
||||||
|
hbox.append(&lbl_titulo);
|
||||||
|
|
||||||
|
// Duración
|
||||||
|
let lbl_dur = Label::new(Some(duracion));
|
||||||
|
lbl_dur.add_css_class("dim-label");
|
||||||
|
lbl_dur.set_width_chars(10);
|
||||||
|
lbl_dur.set_halign(gtk4::Align::End);
|
||||||
|
hbox.append(&lbl_dur);
|
||||||
|
|
||||||
|
// Botones de acción (▶ ↑ ↓ ✗) — azules, compactos
|
||||||
|
let btn_play_row = Button::with_label("▶");
|
||||||
|
let btn_up_row = Button::with_label("↑");
|
||||||
|
let btn_dn_row = Button::with_label("↓");
|
||||||
|
let btn_del_row = Button::with_label("✗");
|
||||||
|
|
||||||
|
btn_play_row.set_tooltip_text(Some("Reproducir ahora"));
|
||||||
|
btn_up_row.set_tooltip_text(Some("Subir"));
|
||||||
|
btn_dn_row.set_tooltip_text(Some("Bajar"));
|
||||||
|
btn_del_row.set_tooltip_text(Some("Eliminar"));
|
||||||
|
|
||||||
|
for b in [&btn_play_row, &btn_up_row, &btn_dn_row, &btn_del_row] {
|
||||||
|
b.add_css_class("suggested-action");
|
||||||
|
b.add_css_class("pl-row-btn");
|
||||||
|
hbox.append(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conectar botones
|
||||||
|
let ruta_s = ruta.to_string();
|
||||||
|
{
|
||||||
|
let c = conexion.clone(); let r = ruta_s.clone();
|
||||||
|
btn_play_row.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::ReproducirAhora { ruta: r.clone() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_up_row.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::MoverEnPlaylist { indice, arriba: true });
|
||||||
|
con.enviar(MsgCliente::ObtenerPlaylist);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_dn_row.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::MoverEnPlaylist { indice, arriba: false });
|
||||||
|
con.enviar(MsgCliente::ObtenerPlaylist);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let c = conexion.clone();
|
||||||
|
btn_del_row.connect_clicked(move |_| {
|
||||||
|
if let Some(ref con) = *c.borrow() {
|
||||||
|
con.enviar(MsgCliente::EliminarDePlaylist { indice });
|
||||||
|
con.enviar(MsgCliente::ObtenerPlaylist);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
row.set_child(Some(&hbox));
|
||||||
|
row.set_activatable(false);
|
||||||
|
row.set_selectable(false);
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contar_filas(list_box: &ListBox) -> usize {
|
||||||
|
let mut count = 0usize;
|
||||||
|
let mut child = list_box.first_child();
|
||||||
|
while let Some(c) = child {
|
||||||
|
count += 1;
|
||||||
|
child = c.next_sibling();
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_tiempo(secs: f64) -> String {
|
||||||
|
let s = secs as u64;
|
||||||
|
format!("{}:{:02}", s / 60, s % 60)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// panel_visor.rs — Visor de reportes del servidor
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Box as GtkBox, Button, ComboBoxText, Label, Orientation,
|
||||||
|
ScrolledWindow, Separator, TextView, TextBuffer, Entry,
|
||||||
|
};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::conexion::Conexion;
|
||||||
|
use crate::protocol::MsgCliente;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PanelVisor {
|
||||||
|
pub root: GtkBox,
|
||||||
|
combo_tipo: ComboBoxText,
|
||||||
|
entry_fecha: Entry,
|
||||||
|
buffer: TextBuffer,
|
||||||
|
lbl_info: Label,
|
||||||
|
conexion: Rc<RefCell<Option<Conexion>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelVisor {
|
||||||
|
pub fn nuevo(conexion: &Rc<RefCell<Option<Conexion>>>) -> Self {
|
||||||
|
let root = GtkBox::new(Orientation::Vertical, 4);
|
||||||
|
root.set_margin_top(6);
|
||||||
|
root.set_margin_bottom(6);
|
||||||
|
root.set_margin_start(6);
|
||||||
|
root.set_margin_end(6);
|
||||||
|
|
||||||
|
// Controles
|
||||||
|
let fila_ctrl = GtkBox::new(Orientation::Horizontal, 8);
|
||||||
|
fila_ctrl.append(&Label::new(Some("Reporte:")));
|
||||||
|
|
||||||
|
let combo_tipo = ComboBoxText::new();
|
||||||
|
combo_tipo.append(Some("emitidos"), "Audios emitidos");
|
||||||
|
combo_tipo.append(Some("playlist"), "Playlist actual");
|
||||||
|
combo_tipo.append(Some("comerciales"), "Comerciales activos");
|
||||||
|
combo_tipo.append(Some("eventos"), "Eventos activos");
|
||||||
|
combo_tipo.set_active_id(Some("emitidos"));
|
||||||
|
fila_ctrl.append(&combo_tipo);
|
||||||
|
|
||||||
|
let lbl_fecha = Label::new(Some("Fecha:"));
|
||||||
|
lbl_fecha.set_margin_start(12);
|
||||||
|
fila_ctrl.append(&lbl_fecha);
|
||||||
|
|
||||||
|
let entry_fecha = Entry::new();
|
||||||
|
entry_fecha.set_width_chars(12);
|
||||||
|
let hoy = chrono::Local::now().format("%Y%m%d").to_string();
|
||||||
|
entry_fecha.set_text(&hoy);
|
||||||
|
entry_fecha.set_placeholder_text(Some("YYYYMMDD"));
|
||||||
|
fila_ctrl.append(&entry_fecha);
|
||||||
|
|
||||||
|
let btn_cargar = Button::with_label("⟳ Cargar");
|
||||||
|
btn_cargar.add_css_class("suggested-action");
|
||||||
|
btn_cargar.set_margin_start(8);
|
||||||
|
fila_ctrl.append(&btn_cargar);
|
||||||
|
|
||||||
|
root.append(&fila_ctrl);
|
||||||
|
root.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
let lbl_info = Label::new(None);
|
||||||
|
lbl_info.add_css_class("dim-label");
|
||||||
|
lbl_info.set_halign(gtk4::Align::Start);
|
||||||
|
lbl_info.set_margin_top(4);
|
||||||
|
lbl_info.set_margin_bottom(4);
|
||||||
|
root.append(&lbl_info);
|
||||||
|
|
||||||
|
// Área de texto
|
||||||
|
let buffer = TextBuffer::new(None::<>k4::TextTagTable>);
|
||||||
|
let text_view = TextView::with_buffer(&buffer);
|
||||||
|
text_view.set_editable(false);
|
||||||
|
text_view.set_cursor_visible(false);
|
||||||
|
text_view.set_monospace(true);
|
||||||
|
text_view.set_hexpand(true);
|
||||||
|
text_view.set_vexpand(true);
|
||||||
|
text_view.set_margin_top(4);
|
||||||
|
text_view.set_margin_bottom(4);
|
||||||
|
text_view.set_margin_start(4);
|
||||||
|
text_view.set_margin_end(4);
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&text_view));
|
||||||
|
root.append(&scroll);
|
||||||
|
|
||||||
|
// Señales
|
||||||
|
{
|
||||||
|
let combo2 = combo_tipo.clone();
|
||||||
|
let entry2 = entry_fecha.clone();
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
let lbl2 = lbl_info.clone();
|
||||||
|
let cargar = move || {
|
||||||
|
let tipo = combo2.active_id().map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
let fecha = entry2.text().to_string();
|
||||||
|
lbl2.set_text("Cargando…");
|
||||||
|
if let Some(ref con) = *conexion2.borrow() {
|
||||||
|
con.enviar(MsgCliente::ObtenerReportes { tipo_r: tipo, fecha });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let c2 = cargar.clone();
|
||||||
|
btn_cargar.connect_clicked(move |_| c2());
|
||||||
|
combo_tipo.connect_changed(move |_| cargar());
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelVisor { root, combo_tipo, entry_fecha, buffer, lbl_info, conexion: conexion.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mostrar_reporte(&self, lineas: &[String]) {
|
||||||
|
self.buffer.set_text(&lineas.join("\n"));
|
||||||
|
self.lbl_info.set_text(&format!("{} líneas", lineas.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_activo(&self, activo: bool) {
|
||||||
|
self.combo_tipo.set_sensitive(activo);
|
||||||
|
self.entry_fecha.set_sensitive(activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
// ventana_principal.rs — Ventana principal de gr-client
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Application, ApplicationWindow, Box as GtkBox, CssProvider, Label, Notebook,
|
||||||
|
Orientation, Paned, Separator, STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
const ICON_GR_CLIENT: &[u8] = include_bytes!("../../assets/gr-client.png");
|
||||||
|
const ICON_GR_CLIENT_SVG: &[u8] = include_bytes!("../../assets/gr-client.svg");
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::conexion::{Conexion, EstadoCon};
|
||||||
|
use crate::estado::EstadoCliente;
|
||||||
|
use crate::protocol::MsgServidor;
|
||||||
|
use crate::ui::panel_buscador::PanelBuscador;
|
||||||
|
use crate::ui::panel_botonera::PanelBotonera;
|
||||||
|
use crate::ui::panel_comercios::PanelComercial;
|
||||||
|
use crate::ui::panel_conexion::PanelConexion;
|
||||||
|
use crate::ui::panel_parrilla::PanelParrilla;
|
||||||
|
use crate::ui::panel_pautaje::PanelPautaje;
|
||||||
|
use crate::ui::panel_player::PanelPlayer;
|
||||||
|
use crate::ui::panel_visor::PanelVisor;
|
||||||
|
|
||||||
|
const APP_CSS: &str = r#"
|
||||||
|
.panel-conexion {
|
||||||
|
border-bottom: 1px solid alpha(@borders, 0.5);
|
||||||
|
}
|
||||||
|
.estado-conectado { color: #4caf50; font-weight: bold; }
|
||||||
|
.estado-desconectado { color: #9e9e9e; }
|
||||||
|
.estado-error { color: #f44336; }
|
||||||
|
.player-track-label { font-size: 1.1em; font-weight: bold; }
|
||||||
|
.player-btn { min-width: 90px; }
|
||||||
|
.dim-label { opacity: 0.7; font-size: 0.9em; }
|
||||||
|
.botonera-btn { font-weight: bold; }
|
||||||
|
.remoto-label { opacity: 0.75; font-size: 0.85em; font-style: italic; }
|
||||||
|
.pl-row-btn { min-width: 26px; padding: 2px 5px; font-size: 0.85em; }
|
||||||
|
.playlist-box row { border-bottom: 1px solid alpha(@borders, 0.3); }
|
||||||
|
.comercios-list row { border-bottom: 1px solid alpha(@borders, 0.3); }
|
||||||
|
.com-stop-btn { min-width: 100px; }
|
||||||
|
.com-vaciar-btn { min-width: 110px; }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn construir(app: &Application) {
|
||||||
|
let estado = Rc::new(RefCell::new(EstadoCliente::nuevo()));
|
||||||
|
let conexion: Rc<RefCell<Option<Conexion>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
// Canales mpsc para comunicación tokio → GTK
|
||||||
|
// Los canales son bounded (100) para evitar acumulación de mensajes.
|
||||||
|
let (tx_server, rx_server) = mpsc::sync_channel::<MsgServidor>(100);
|
||||||
|
let (tx_estado_con, rx_estado_con) = mpsc::sync_channel::<EstadoCon>(100);
|
||||||
|
|
||||||
|
// ── Ventana ───────────────────────────────────────────────────────────────
|
||||||
|
let window = ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.title(crate::i18n::tr("win.title"))
|
||||||
|
.default_width(1400)
|
||||||
|
.default_height(750)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let provider = CssProvider::new();
|
||||||
|
provider.load_from_data(APP_CSS);
|
||||||
|
gtk4::style_context_add_provider_for_display(
|
||||||
|
>k4::prelude::WidgetExt::display(&window),
|
||||||
|
&provider,
|
||||||
|
STYLE_PROVIDER_PRIORITY_APPLICATION + 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
let vbox = GtkBox::new(Orientation::Vertical, 0);
|
||||||
|
|
||||||
|
// ── Barra de conexión ─────────────────────────────────────────────────────
|
||||||
|
let panel_con = PanelConexion::nuevo(&estado);
|
||||||
|
vbox.append(&panel_con.root);
|
||||||
|
vbox.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
|
||||||
|
// Listener del selector de idioma → guardar config + mostrar "reinicia para aplicar"
|
||||||
|
{
|
||||||
|
let est_lang = estado.clone();
|
||||||
|
let win_lang = window.clone();
|
||||||
|
panel_con.combo_locale.connect_changed(move |c| {
|
||||||
|
let id = c.active_id().map(|s| s.to_string()).unwrap_or_else(|| "auto".into());
|
||||||
|
let new_loc = if id == "auto" { None } else { Some(id) };
|
||||||
|
let mut est = est_lang.borrow_mut();
|
||||||
|
if est.config.locale != new_loc {
|
||||||
|
est.config.locale = new_loc;
|
||||||
|
est.config.guardar();
|
||||||
|
drop(est);
|
||||||
|
show_lang_restart_dialog(&win_lang);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Área principal: Paned(buscador | notebook) ────────────────────────────
|
||||||
|
let panel_player = PanelPlayer::nuevo(&estado, &conexion);
|
||||||
|
let panel_pautaje = PanelPautaje::nuevo(&conexion);
|
||||||
|
let panel_parrilla = PanelParrilla::nuevo(&conexion);
|
||||||
|
let panel_buscador = PanelBuscador::nuevo(&conexion);
|
||||||
|
let panel_botonera = PanelBotonera::nuevo(&conexion);
|
||||||
|
let panel_visor = PanelVisor::nuevo(&conexion);
|
||||||
|
let panel_comercial = PanelComercial::nuevo(&conexion);
|
||||||
|
|
||||||
|
// Buscador a la izquierda: siempre visible para poder arrastrar
|
||||||
|
let lbl_busq = Label::new(Some(crate::i18n::tr("tab.search")));
|
||||||
|
lbl_busq.set_margin_bottom(4);
|
||||||
|
let buscador_box = GtkBox::new(Orientation::Vertical, 0);
|
||||||
|
buscador_box.append(&lbl_busq);
|
||||||
|
buscador_box.append(&Separator::new(Orientation::Horizontal));
|
||||||
|
buscador_box.append(&panel_buscador.root);
|
||||||
|
buscador_box.set_vexpand(true);
|
||||||
|
|
||||||
|
let notebook = Notebook::new();
|
||||||
|
notebook.set_vexpand(true);
|
||||||
|
notebook.set_hexpand(true);
|
||||||
|
notebook.append_page(&panel_player.root, Some(&Label::new(Some(crate::i18n::tr("tab.player")))));
|
||||||
|
notebook.append_page(&panel_pautaje.root, Some(&Label::new(Some(crate::i18n::tr("tab.pautaje")))));
|
||||||
|
notebook.append_page(&panel_parrilla.root, Some(&Label::new(Some(crate::i18n::tr("tab.parrilla")))));
|
||||||
|
notebook.append_page(&panel_botonera.root, Some(&Label::new(Some(crate::i18n::tr("tab.botonera")))));
|
||||||
|
notebook.append_page(&panel_comercial.root, Some(&Label::new(Some(crate::i18n::tr("tab.comercios")))));
|
||||||
|
notebook.append_page(&panel_visor.root, Some(&Label::new(Some(crate::i18n::tr("tab.reportes")))));
|
||||||
|
|
||||||
|
let paned = Paned::new(Orientation::Horizontal);
|
||||||
|
paned.set_start_child(Some(&buscador_box));
|
||||||
|
paned.set_end_child(Some(¬ebook));
|
||||||
|
paned.set_position(320); // ancho inicial del buscador
|
||||||
|
paned.set_shrink_start_child(false);
|
||||||
|
paned.set_vexpand(true);
|
||||||
|
|
||||||
|
vbox.append(&paned);
|
||||||
|
|
||||||
|
window.set_child(Some(&vbox));
|
||||||
|
|
||||||
|
// Pasar referencia de ventana a paneles que abren diálogos
|
||||||
|
{
|
||||||
|
let win_ref = window.clone();
|
||||||
|
let pp = panel_pautaje.clone();
|
||||||
|
let par = panel_parrilla.clone();
|
||||||
|
window.connect_realize(move |_| {
|
||||||
|
pp.set_ventana_padre(win_ref.upcast_ref::<gtk4::Window>());
|
||||||
|
par.set_ventana_padre(win_ref.upcast_ref::<gtk4::Window>());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado inicial desconectado
|
||||||
|
panel_player.set_activo(false);
|
||||||
|
panel_pautaje.set_activo(false);
|
||||||
|
panel_parrilla.set_activo(false);
|
||||||
|
panel_buscador.set_activo(false);
|
||||||
|
panel_botonera.set_activo(false);
|
||||||
|
panel_comercial.set_activo(false);
|
||||||
|
panel_visor.set_activo(false);
|
||||||
|
|
||||||
|
// ── Botón conectar/desconectar ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
let conexion2 = conexion.clone();
|
||||||
|
let estado2 = estado.clone();
|
||||||
|
let panel_con2 = panel_con.clone();
|
||||||
|
let tx_server2 = tx_server.clone();
|
||||||
|
let tx_estado2 = tx_estado_con.clone();
|
||||||
|
panel_con.conectar_boton_clicked(move || {
|
||||||
|
let mut est = estado2.borrow_mut();
|
||||||
|
if est.conectado || conexion2.borrow().is_some() {
|
||||||
|
// Desconectar o cancelar reconexión en curso
|
||||||
|
*conexion2.borrow_mut() = None;
|
||||||
|
est.conectado = false;
|
||||||
|
let _ = tx_estado2.send(EstadoCon::Desconectado);
|
||||||
|
} else {
|
||||||
|
let token = panel_con2.obtener_token();
|
||||||
|
let modo_relay = panel_con2.es_modo_relay();
|
||||||
|
|
||||||
|
if modo_relay {
|
||||||
|
let relay_id = panel_con2.obtener_relay_id();
|
||||||
|
|
||||||
|
// Token obligatorio en modo relay
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
panel_con2.mostrar_error_token();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ID debe ser 8 dígitos
|
||||||
|
if relay_id.len() != 8 || !relay_id.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
panel_con2.mostrar_error_id();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
est.config.modo_relay = true;
|
||||||
|
est.config.relay_id = relay_id.clone();
|
||||||
|
est.config.token = token.clone();
|
||||||
|
est.config.guardar();
|
||||||
|
drop(est);
|
||||||
|
|
||||||
|
let nueva_con = Conexion::conectar_relay(
|
||||||
|
relay_id, token,
|
||||||
|
tx_server2.clone(),
|
||||||
|
tx_estado2.clone(),
|
||||||
|
);
|
||||||
|
*conexion2.borrow_mut() = Some(nueva_con);
|
||||||
|
} else {
|
||||||
|
let servidor = panel_con2.obtener_servidor();
|
||||||
|
let puerto = panel_con2.obtener_puerto();
|
||||||
|
est.config.modo_relay = false;
|
||||||
|
est.config.servidor = servidor.clone();
|
||||||
|
est.config.puerto = puerto;
|
||||||
|
est.config.token = token.clone();
|
||||||
|
est.config.guardar();
|
||||||
|
drop(est);
|
||||||
|
|
||||||
|
let nueva_con = Conexion::conectar(
|
||||||
|
servidor, puerto, token,
|
||||||
|
tx_server2.clone(),
|
||||||
|
tx_estado2.clone(),
|
||||||
|
);
|
||||||
|
*conexion2.borrow_mut() = Some(nueva_con);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timer: polling de canales mpsc → despacho a GTK ──────────────────────
|
||||||
|
let rx_server_rc = Rc::new(RefCell::new(rx_server));
|
||||||
|
let rx_estado_rc = Rc::new(RefCell::new(rx_estado_con));
|
||||||
|
|
||||||
|
let panel_player_t = panel_player.clone();
|
||||||
|
let panel_pautaje_t = panel_pautaje.clone();
|
||||||
|
let panel_parrilla_t = panel_parrilla.clone();
|
||||||
|
let panel_buscador_t = panel_buscador.clone();
|
||||||
|
let panel_botonera_t = panel_botonera.clone();
|
||||||
|
let panel_comercial_t = panel_comercial.clone();
|
||||||
|
let panel_visor_t = panel_visor.clone();
|
||||||
|
let panel_con_t = panel_con.clone();
|
||||||
|
let estado_t = estado.clone();
|
||||||
|
let conexion_t = conexion.clone();
|
||||||
|
let window_t = window.clone();
|
||||||
|
|
||||||
|
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||||
|
// Mensajes del servidor
|
||||||
|
while let Ok(msg) = rx_server_rc.borrow().try_recv() {
|
||||||
|
despachar_mensaje(
|
||||||
|
msg,
|
||||||
|
&panel_player_t,
|
||||||
|
&panel_pautaje_t,
|
||||||
|
&panel_parrilla_t,
|
||||||
|
&panel_buscador_t,
|
||||||
|
&panel_botonera_t,
|
||||||
|
&panel_comercial_t,
|
||||||
|
&panel_visor_t,
|
||||||
|
&estado_t,
|
||||||
|
&window_t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Cambios de estado de conexión
|
||||||
|
while let Ok(est_con) = rx_estado_rc.borrow().try_recv() {
|
||||||
|
let activo = matches!(est_con, EstadoCon::Conectado { .. });
|
||||||
|
// Actualizar indicador de conexión remota en el player
|
||||||
|
if let EstadoCon::Conectado { .. } = &est_con {
|
||||||
|
let cfg = &estado_t.borrow().config;
|
||||||
|
let info = if cfg.modo_relay {
|
||||||
|
"Remoto desde Internet".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Remoto desde: {}:{}", cfg.servidor, cfg.puerto)
|
||||||
|
};
|
||||||
|
panel_player_t.set_info_remoto(&info);
|
||||||
|
// Pedir estado inicial de todos los paneles al conectar
|
||||||
|
if let Some(ref con) = *conexion_t.borrow() {
|
||||||
|
con.enviar(crate::protocol::MsgCliente::ObtenerEstado);
|
||||||
|
con.enviar(crate::protocol::MsgCliente::ObtenerPlaylist);
|
||||||
|
con.enviar(crate::protocol::MsgCliente::ObtenerBotonera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel_con_t.actualizar_estado(&est_con);
|
||||||
|
panel_player_t.set_activo(activo);
|
||||||
|
panel_pautaje_t.set_activo(activo);
|
||||||
|
panel_parrilla_t.set_activo(activo);
|
||||||
|
panel_buscador_t.set_activo(activo);
|
||||||
|
panel_botonera_t.set_activo(activo);
|
||||||
|
panel_comercial_t.set_activo(activo);
|
||||||
|
panel_visor_t.set_activo(activo);
|
||||||
|
estado_t.borrow_mut().conectado = activo;
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instalar ícono en hicolor — siempre sobreescribe para reflejar actualizaciones
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let hicolor = home.join(".local/share/icons/hicolor");
|
||||||
|
let mut ok = false;
|
||||||
|
// PNG en tamaños fijos
|
||||||
|
for size in ["48x48", "256x256"] {
|
||||||
|
let dir = hicolor.join(size).join("apps");
|
||||||
|
let _ = fs::create_dir_all(&dir);
|
||||||
|
ok |= fs::write(dir.join("gr-client.png"), ICON_GR_CLIENT).is_ok();
|
||||||
|
}
|
||||||
|
// SVG escalable (mejor calidad en menús de escritorio modernos)
|
||||||
|
let svg_dir = hicolor.join("scalable").join("apps");
|
||||||
|
let _ = fs::create_dir_all(&svg_dir);
|
||||||
|
ok |= fs::write(svg_dir.join("gr-client.svg"), ICON_GR_CLIENT_SVG).is_ok();
|
||||||
|
if ok {
|
||||||
|
let _ = std::process::Command::new("gtk-update-icon-cache")
|
||||||
|
.args(["-f", "-t", &hicolor.to_string_lossy().into_owned()])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ícono en titlebar/taskbar vía GTK
|
||||||
|
{
|
||||||
|
let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
|
||||||
|
let _ = loader.write(ICON_GR_CLIENT);
|
||||||
|
let _ = loader.close();
|
||||||
|
if let Some(pb) = loader.pixbuf() {
|
||||||
|
let pb48 = pb.scale_simple(48, 48, gdk4::gdk_pixbuf::InterpType::Bilinear)
|
||||||
|
.unwrap_or(pb);
|
||||||
|
let tex = gdk4::Texture::for_pixbuf(&pb48);
|
||||||
|
window.connect_realize(move |w| {
|
||||||
|
if let Some(surf) = w.surface() {
|
||||||
|
use gdk4::prelude::ToplevelExt;
|
||||||
|
if let Some(tl) = surf.dynamic_cast_ref::<gdk4::Toplevel>() {
|
||||||
|
tl.set_icon_list(&[tex.clone()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Despachador de mensajes del servidor ────────────────────────────────────
|
||||||
|
|
||||||
|
fn despachar_mensaje(
|
||||||
|
msg: MsgServidor,
|
||||||
|
player: &PanelPlayer,
|
||||||
|
pautaje: &PanelPautaje,
|
||||||
|
parrilla: &PanelParrilla,
|
||||||
|
buscador: &PanelBuscador,
|
||||||
|
botonera: &PanelBotonera,
|
||||||
|
comercial: &PanelComercial,
|
||||||
|
visor: &PanelVisor,
|
||||||
|
estado: &Rc<RefCell<EstadoCliente>>,
|
||||||
|
window: &ApplicationWindow,
|
||||||
|
) {
|
||||||
|
match msg {
|
||||||
|
MsgServidor::PlaylistObtenida { tracks }
|
||||||
|
| MsgServidor::PlaylistCambiada { tracks } => {
|
||||||
|
let indice = estado.borrow().estado_player.indice_actual;
|
||||||
|
player.actualizar_playlist(&tracks, indice);
|
||||||
|
estado.borrow_mut().playlist = tracks;
|
||||||
|
}
|
||||||
|
MsgServidor::EstadoObtenido { estado: est }
|
||||||
|
| MsgServidor::EstadoCambiado { estado: est } => {
|
||||||
|
let indice = est.indice_actual;
|
||||||
|
player.actualizar_estado_player(&est);
|
||||||
|
let tracks = estado.borrow().playlist.clone();
|
||||||
|
if !tracks.is_empty() {
|
||||||
|
player.actualizar_playlist(&tracks, indice);
|
||||||
|
}
|
||||||
|
estado.borrow_mut().estado_player = est;
|
||||||
|
}
|
||||||
|
MsgServidor::PautajeObtenido { tipo_p, hora, minuto, entradas } => {
|
||||||
|
pautaje.cargar_entradas(&tipo_p, hora, minuto, &entradas);
|
||||||
|
}
|
||||||
|
MsgServidor::ParrillaObtenida { dia, hora, items } => {
|
||||||
|
parrilla.cargar_items(dia, hora, &items);
|
||||||
|
}
|
||||||
|
MsgServidor::ResultadosBusqueda { resultados } => {
|
||||||
|
buscador.cargar_resultados(&resultados);
|
||||||
|
}
|
||||||
|
MsgServidor::BotoneraObtenida { botones } => {
|
||||||
|
botonera.cargar_botones(&botones);
|
||||||
|
}
|
||||||
|
MsgServidor::ReportesObtenidos { lineas } => {
|
||||||
|
visor.mostrar_reporte(&lineas);
|
||||||
|
}
|
||||||
|
MsgServidor::ColaObtenida { tipo_cola, items } => {
|
||||||
|
comercial.actualizar_cola(&tipo_cola, &items);
|
||||||
|
}
|
||||||
|
MsgServidor::IndiceActualizado { ok, mensaje } => {
|
||||||
|
buscador.mostrar_resultado_indice(ok, &mensaje);
|
||||||
|
}
|
||||||
|
MsgServidor::Error { mensaje } => {
|
||||||
|
log::warn!("Error del servidor: {}", mensaje);
|
||||||
|
let dialog = gtk4::MessageDialog::new(
|
||||||
|
Some(window),
|
||||||
|
gtk4::DialogFlags::MODAL | gtk4::DialogFlags::DESTROY_WITH_PARENT,
|
||||||
|
gtk4::MessageType::Warning,
|
||||||
|
gtk4::ButtonsType::Ok,
|
||||||
|
&format!("Audio rechazado por el servidor:\n{}", mensaje),
|
||||||
|
);
|
||||||
|
dialog.connect_response(|d, _| d.destroy());
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
_ => {} // Ok, Pong, AuthOk, GuardadoX, InfoAudio, DirectorioListado
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_lang_restart_dialog(parent: &ApplicationWindow) {
|
||||||
|
let dialog = gtk4::MessageDialog::new(
|
||||||
|
Some(parent),
|
||||||
|
gtk4::DialogFlags::MODAL | gtk4::DialogFlags::DESTROY_WITH_PARENT,
|
||||||
|
gtk4::MessageType::Info,
|
||||||
|
gtk4::ButtonsType::Ok,
|
||||||
|
crate::i18n::tr("lang.restart_body"),
|
||||||
|
);
|
||||||
|
dialog.set_title(Some(crate::i18n::tr("lang.restart_title")));
|
||||||
|
dialog.connect_response(|d, _| d.destroy());
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ─── G Radio Client — desinstalador ──────────────────────────────────────────
|
||||||
|
echo "==> Desinstalando G Radio Client..."
|
||||||
|
|
||||||
|
rm -f "$HOME/.local/bin/gr-client"
|
||||||
|
rm -f "$HOME/.local/share/applications/gr-client.desktop"
|
||||||
|
|
||||||
|
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "✅ Desinstalado."
|
||||||
|
echo " Los datos en ~/.gradio/client_config.json y ~/.gradio/data/locatedb"
|
||||||
|
echo " no se han borrado. Elimínalos manualmente si lo deseas."
|
||||||
Reference in New Issue
Block a user