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:
2026-08-23 01:15:15 -05:00
commit 2219b3abc0
28 changed files with 7332 additions and 0 deletions
+30
View File
@@ -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.*#
+15
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
+663
View File
@@ -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 (0100). 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 (0023), 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

+480
View File
@@ -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

Executable
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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: &gtk4::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
View File
@@ -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,
}
+9
View File
@@ -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;
+210
View File
@@ -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<&gtk4::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();
}
+253
View File
@@ -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);
}
}
+350
View File
@@ -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
}
+265
View File
@@ -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);
}
}
}
}
+391
View File
@@ -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)
}
+501
View File
@@ -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();
}
+388
View File
@@ -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)
}
+119
View File
@@ -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::<&gtk4::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);
}
}
+408
View File
@@ -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(
&gtk4::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(&notebook));
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
View File
@@ -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."